From 3833a9dd8fc58b757e44fae76f5b0b374421202a Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 22 Aug 2026 02:35:08 +0200 Subject: [PATCH] docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert. README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind US-Unternehmen und unterliegen auch bei EU-Region potenziell dem CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person ist das eine Abwaegung, die man bewusst treffen muss. Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon steht. Die Datenbank hat keine Portfreigabe. Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt dauerhaft stehen. Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern auch der Schluessel. Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie sollen der betreuten Person vorgelesen werden koennen. Die Seite /datenschutz liest denselben Text aus docs/, damit es nicht zwei Fassungen gibt, die auseinanderlaufen. Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der Server in UTC und verschiebt Fristen um einen Tag. Etappe: 1 Phase: Fundament --- .gitignore | 4 + Dockerfile | 93 ++ README.md | 524 ++++++++- docker-compose.yml | 1 + docs/datenschutzhinweise.md | 134 +++ docs/verarbeitungsverzeichnis.md | 193 ++++ scripts/sicherung.sh | 85 ++ scripts/wiederherstellung.sh | 100 ++ src/app/(app)/bestaetigen/formular.tsx | 54 + src/app/(app)/bestaetigen/page.tsx | 43 + src/app/(app)/dokumente/neu/page.tsx | 69 ++ src/app/(app)/dokumente/page.tsx | 184 +++ src/app/(app)/dokumente/posteingang/page.tsx | 143 +++ src/app/(app)/finanzen/buchungen/neu/page.tsx | 103 ++ src/app/(app)/finanzen/buchungen/page.tsx | 286 +++++ src/app/(app)/finanzen/konten/[id]/page.tsx | 245 ++++ src/app/(app)/finanzen/konten/neu/page.tsx | 30 + src/app/(app)/finanzen/konten/page.tsx | 123 ++ src/app/(app)/finanzen/page.tsx | 318 +++++ src/app/(app)/hinweise/page.tsx | 58 + .../(app)/immobilie/[id]/bearbeiten/page.tsx | 67 ++ .../(app)/immobilie/[id]/checkliste/page.tsx | 95 ++ .../(app)/immobilie/[id]/inventar/page.tsx | 90 ++ src/app/(app)/immobilie/[id]/page.tsx | 225 ++++ src/app/(app)/immobilie/neu/page.tsx | 31 + src/app/(app)/immobilie/page.tsx | 167 +++ src/app/(app)/konto/page.tsx | 109 ++ src/app/(app)/konto/passwort-formular.tsx | 72 ++ .../(app)/konto/zwei-faktor/neue-codes.tsx | 71 ++ src/app/(app)/konto/zwei-faktor/page.tsx | 77 ++ .../[antragId]/bearbeiten/page.tsx | 60 + .../bescheide/[bescheidId]/page.tsx | 149 +++ .../[antragId]/bescheide/neu/page.tsx | 47 + src/app/(app)/sozialhilfe/[antragId]/page.tsx | 262 +++++ src/app/(app)/sozialhilfe/heimkosten/page.tsx | 318 +++++ src/app/(app)/sozialhilfe/leistungen/page.tsx | 76 ++ src/app/(app)/sozialhilfe/neu/page.tsx | 49 + src/app/(app)/sozialhilfe/page.tsx | 276 +++++ src/app/(app)/sozialhilfe/vermoegen/page.tsx | 229 ++++ .../api/dokumente/datei/[signatur]/route.ts | 170 +++ src/app/datenschutz/page.tsx | 114 ++ .../berichte/belegmappe-formular.tsx | 111 ++ src/components/berichte/entwurf-editor.tsx | 194 ++++ .../berichte/jahresbericht-formular.tsx | 79 ++ src/components/berichte/meldung.tsx | 46 + src/components/dokumente/abzeichen.tsx | 85 ++ .../dokumente/belegmappe-formular.tsx | 128 +++ src/components/dokumente/datei-aktionen.tsx | 157 +++ src/components/dokumente/dokument-liste.tsx | 158 +++ src/components/dokumente/erkennung-panel.tsx | 365 ++++++ src/components/dokumente/filter-leiste.tsx | 134 +++ .../dokumente/metadaten-formular.tsx | 240 ++++ .../dokumente/posteingang-stufen.tsx | 75 ++ src/components/dokumente/upload-formular.tsx | 298 +++++ src/components/dokumente/verknuepfungen.tsx | 178 +++ src/components/finanzen/aktionen.tsx | 70 ++ src/components/finanzen/bausteine.tsx | 206 ++++ src/components/finanzen/buchungs-filter.tsx | 167 +++ src/components/finanzen/buchungs-formular.tsx | 343 ++++++ src/components/finanzen/eingaben.tsx | 135 +++ src/components/finanzen/import-formular.tsx | 136 +++ src/components/finanzen/konto-formular.tsx | 183 +++ src/components/finanzen/posten-formulare.tsx | 283 +++++ src/components/finanzen/saldo-formular.tsx | 100 ++ src/components/finanzen/wertverlauf.tsx | 175 +++ src/components/hinweise/HinweisKarten.tsx | 57 + src/components/hinweise/hinweis-karte.tsx | 44 + src/components/hinweise/hinweis-suche.tsx | 82 ++ src/components/immobilie/belastungen.tsx | 254 ++++ src/components/immobilie/checkliste.tsx | 262 +++++ .../immobilie/immobilie-formular.tsx | 231 ++++ src/components/immobilie/inventar.tsx | 254 ++++ src/components/immobilie/kostenkonto.tsx | 155 +++ .../immobilie/status-sozialhilfe.tsx | 125 ++ src/components/immobilie/wert-historie.tsx | 166 +++ src/components/sozialhilfe/abzeichen.tsx | 53 + .../sozialhilfe/antrag-formular.tsx | 168 +++ src/components/sozialhilfe/bausteine.tsx | 87 ++ .../sozialhilfe/bescheid-formular.tsx | 225 ++++ .../sozialhilfe/bescheid-pruefung.tsx | 200 ++++ .../sozialhilfe/bewertung-formular.tsx | 180 +++ .../sozialhilfe/checkliste-panel.tsx | 143 +++ .../sozialhilfe/heimkosten-formular.tsx | 326 ++++++ .../sozialhilfe/heimkosten-tabelle.tsx | 85 ++ .../sozialhilfe/leistungen-liste.tsx | 298 +++++ .../sozialhilfe/nachforderung-formular.tsx | 184 +++ .../sozialhilfe/nachforderung-tracker.tsx | 301 +++++ .../sozialhilfe/vermoegensliste.tsx | 173 +++ src/components/substanz-formular.tsx | 155 +++ src/components/vertraege/vertrag-formular.tsx | 248 ++++ src/lib/berichte/docx.ts | 213 ++++ src/lib/berichte/gemeinsam.ts | 417 +++++++ src/lib/berichte/jahresbericht.ts | 694 +++++++++++ src/lib/berichte/jahresbericht.tsx | 234 ++++ src/lib/berichte/rechnungslegung.tsx | 507 ++++++++ src/lib/berichte/unterlagenpaket.ts | 441 +++++++ src/lib/berichte/vermoegensverzeichnis.tsx | 376 ++++++ src/lib/berichte/vollexport.ts | 531 +++++++++ src/lib/dokumente/belegmappe.tsx | 350 ++++++ src/lib/dokumente/erkennung.ts | 109 +- src/lib/dokumente/kontext.ts | 44 + src/lib/dokumente/ocr-nachlauf.ts | 89 ++ src/lib/dokumente/suchbegriff.ts | 44 + src/lib/dokumente/suche.ts | 43 +- src/lib/dokumente/ziele.ts | 338 ++++++ src/lib/finanzen/seite.ts | 19 + src/lib/finanzen/vermoegen.ts | 5 + src/lib/pdf/rahmen.tsx | 5 +- src/lib/pflege/ereignis-details.ts | 223 ++++ src/lib/pflege/impfungen.ts | 173 +++ src/lib/sozialhilfe/arten.ts | 61 + src/lib/sozialhilfe/bescheide.ts | 140 +++ src/lib/sozialhilfe/betrag.ts | 67 ++ src/lib/sozialhilfe/zugang.ts | 36 + src/lib/validierung/berichte.ts | 286 +++++ src/lib/validierung/dokumente.ts | 315 +++++ src/lib/validierung/finanzen.ts | 9 +- src/lib/validierung/freigaben.ts | 183 +++ src/lib/validierung/immobilie.ts | 171 +++ src/lib/validierung/pflege.ts | 123 ++ src/lib/validierung/sozialhilfe.ts | 437 +++++++ src/lib/validierung/substanz.ts | 167 +++ src/lib/validierung/vertraege.ts | 172 +++ src/server/actions/berichte.ts | 638 +++++++++++ src/server/actions/bestaetigen.ts | 74 ++ src/server/actions/dokumente.ts | 978 ++++++++++++++++ src/server/actions/finanzen.ts | 890 ++++++++++++++ src/server/actions/freigaben.ts | 385 +++++++ src/server/actions/immobilie.ts | 716 ++++++++++++ src/server/actions/konto.ts | 104 ++ src/server/actions/papierkorb.ts | 257 +++++ src/server/actions/pflege.ts | 355 ++++++ src/server/actions/sozialhilfe.ts | 1020 +++++++++++++++++ src/server/actions/vertraege.ts | 453 ++++++++ tests/berichte.test.ts | 665 +++++++++++ tests/dokumente.test.ts | 474 ++++++++ tests/finanzen.test.ts | 862 ++++++++++++++ tests/immobilie.test.ts | 199 ++++ tests/pflege.test.ts | 303 +++++ tests/sozialhilfe.test.ts | 638 +++++++++++ tests/vertraege.test.ts | 289 +++++ 141 files changed, 30302 insertions(+), 66 deletions(-) create mode 100644 Dockerfile create mode 100644 docs/datenschutzhinweise.md create mode 100644 docs/verarbeitungsverzeichnis.md create mode 100644 scripts/sicherung.sh create mode 100644 scripts/wiederherstellung.sh create mode 100644 src/app/(app)/bestaetigen/formular.tsx create mode 100644 src/app/(app)/bestaetigen/page.tsx create mode 100644 src/app/(app)/dokumente/neu/page.tsx create mode 100644 src/app/(app)/dokumente/page.tsx create mode 100644 src/app/(app)/dokumente/posteingang/page.tsx create mode 100644 src/app/(app)/finanzen/buchungen/neu/page.tsx create mode 100644 src/app/(app)/finanzen/buchungen/page.tsx create mode 100644 src/app/(app)/finanzen/konten/[id]/page.tsx create mode 100644 src/app/(app)/finanzen/konten/neu/page.tsx create mode 100644 src/app/(app)/finanzen/konten/page.tsx create mode 100644 src/app/(app)/finanzen/page.tsx create mode 100644 src/app/(app)/hinweise/page.tsx create mode 100644 src/app/(app)/immobilie/[id]/bearbeiten/page.tsx create mode 100644 src/app/(app)/immobilie/[id]/checkliste/page.tsx create mode 100644 src/app/(app)/immobilie/[id]/inventar/page.tsx create mode 100644 src/app/(app)/immobilie/[id]/page.tsx create mode 100644 src/app/(app)/immobilie/neu/page.tsx create mode 100644 src/app/(app)/immobilie/page.tsx create mode 100644 src/app/(app)/konto/page.tsx create mode 100644 src/app/(app)/konto/passwort-formular.tsx create mode 100644 src/app/(app)/konto/zwei-faktor/neue-codes.tsx create mode 100644 src/app/(app)/konto/zwei-faktor/page.tsx create mode 100644 src/app/(app)/sozialhilfe/[antragId]/bearbeiten/page.tsx create mode 100644 src/app/(app)/sozialhilfe/[antragId]/bescheide/[bescheidId]/page.tsx create mode 100644 src/app/(app)/sozialhilfe/[antragId]/bescheide/neu/page.tsx create mode 100644 src/app/(app)/sozialhilfe/[antragId]/page.tsx create mode 100644 src/app/(app)/sozialhilfe/heimkosten/page.tsx create mode 100644 src/app/(app)/sozialhilfe/leistungen/page.tsx create mode 100644 src/app/(app)/sozialhilfe/neu/page.tsx create mode 100644 src/app/(app)/sozialhilfe/page.tsx create mode 100644 src/app/(app)/sozialhilfe/vermoegen/page.tsx create mode 100644 src/app/api/dokumente/datei/[signatur]/route.ts create mode 100644 src/app/datenschutz/page.tsx create mode 100644 src/components/berichte/belegmappe-formular.tsx create mode 100644 src/components/berichte/entwurf-editor.tsx create mode 100644 src/components/berichte/jahresbericht-formular.tsx create mode 100644 src/components/berichte/meldung.tsx create mode 100644 src/components/dokumente/abzeichen.tsx create mode 100644 src/components/dokumente/belegmappe-formular.tsx create mode 100644 src/components/dokumente/datei-aktionen.tsx create mode 100644 src/components/dokumente/dokument-liste.tsx create mode 100644 src/components/dokumente/erkennung-panel.tsx create mode 100644 src/components/dokumente/filter-leiste.tsx create mode 100644 src/components/dokumente/metadaten-formular.tsx create mode 100644 src/components/dokumente/posteingang-stufen.tsx create mode 100644 src/components/dokumente/upload-formular.tsx create mode 100644 src/components/dokumente/verknuepfungen.tsx create mode 100644 src/components/finanzen/aktionen.tsx create mode 100644 src/components/finanzen/bausteine.tsx create mode 100644 src/components/finanzen/buchungs-filter.tsx create mode 100644 src/components/finanzen/buchungs-formular.tsx create mode 100644 src/components/finanzen/eingaben.tsx create mode 100644 src/components/finanzen/import-formular.tsx create mode 100644 src/components/finanzen/konto-formular.tsx create mode 100644 src/components/finanzen/posten-formulare.tsx create mode 100644 src/components/finanzen/saldo-formular.tsx create mode 100644 src/components/finanzen/wertverlauf.tsx create mode 100644 src/components/hinweise/HinweisKarten.tsx create mode 100644 src/components/hinweise/hinweis-karte.tsx create mode 100644 src/components/hinweise/hinweis-suche.tsx create mode 100644 src/components/immobilie/belastungen.tsx create mode 100644 src/components/immobilie/checkliste.tsx create mode 100644 src/components/immobilie/immobilie-formular.tsx create mode 100644 src/components/immobilie/inventar.tsx create mode 100644 src/components/immobilie/kostenkonto.tsx create mode 100644 src/components/immobilie/status-sozialhilfe.tsx create mode 100644 src/components/immobilie/wert-historie.tsx create mode 100644 src/components/sozialhilfe/abzeichen.tsx create mode 100644 src/components/sozialhilfe/antrag-formular.tsx create mode 100644 src/components/sozialhilfe/bausteine.tsx create mode 100644 src/components/sozialhilfe/bescheid-formular.tsx create mode 100644 src/components/sozialhilfe/bescheid-pruefung.tsx create mode 100644 src/components/sozialhilfe/bewertung-formular.tsx create mode 100644 src/components/sozialhilfe/checkliste-panel.tsx create mode 100644 src/components/sozialhilfe/heimkosten-formular.tsx create mode 100644 src/components/sozialhilfe/heimkosten-tabelle.tsx create mode 100644 src/components/sozialhilfe/leistungen-liste.tsx create mode 100644 src/components/sozialhilfe/nachforderung-formular.tsx create mode 100644 src/components/sozialhilfe/nachforderung-tracker.tsx create mode 100644 src/components/sozialhilfe/vermoegensliste.tsx create mode 100644 src/components/substanz-formular.tsx create mode 100644 src/components/vertraege/vertrag-formular.tsx create mode 100644 src/lib/berichte/docx.ts create mode 100644 src/lib/berichte/gemeinsam.ts create mode 100644 src/lib/berichte/jahresbericht.ts create mode 100644 src/lib/berichte/jahresbericht.tsx create mode 100644 src/lib/berichte/rechnungslegung.tsx create mode 100644 src/lib/berichte/unterlagenpaket.ts create mode 100644 src/lib/berichte/vermoegensverzeichnis.tsx create mode 100644 src/lib/berichte/vollexport.ts create mode 100644 src/lib/dokumente/belegmappe.tsx create mode 100644 src/lib/dokumente/kontext.ts create mode 100644 src/lib/dokumente/ocr-nachlauf.ts create mode 100644 src/lib/dokumente/suchbegriff.ts create mode 100644 src/lib/dokumente/ziele.ts create mode 100644 src/lib/finanzen/seite.ts create mode 100644 src/lib/pflege/ereignis-details.ts create mode 100644 src/lib/pflege/impfungen.ts create mode 100644 src/lib/sozialhilfe/arten.ts create mode 100644 src/lib/sozialhilfe/bescheide.ts create mode 100644 src/lib/sozialhilfe/betrag.ts create mode 100644 src/lib/sozialhilfe/zugang.ts create mode 100644 src/lib/validierung/berichte.ts create mode 100644 src/lib/validierung/dokumente.ts create mode 100644 src/lib/validierung/freigaben.ts create mode 100644 src/lib/validierung/immobilie.ts create mode 100644 src/lib/validierung/pflege.ts create mode 100644 src/lib/validierung/sozialhilfe.ts create mode 100644 src/lib/validierung/substanz.ts create mode 100644 src/lib/validierung/vertraege.ts create mode 100644 src/server/actions/berichte.ts create mode 100644 src/server/actions/bestaetigen.ts create mode 100644 src/server/actions/dokumente.ts create mode 100644 src/server/actions/finanzen.ts create mode 100644 src/server/actions/freigaben.ts create mode 100644 src/server/actions/immobilie.ts create mode 100644 src/server/actions/konto.ts create mode 100644 src/server/actions/papierkorb.ts create mode 100644 src/server/actions/pflege.ts create mode 100644 src/server/actions/sozialhilfe.ts create mode 100644 src/server/actions/vertraege.ts create mode 100644 tests/berichte.test.ts create mode 100644 tests/dokumente.test.ts create mode 100644 tests/finanzen.test.ts create mode 100644 tests/immobilie.test.ts create mode 100644 tests/pflege.test.ts create mode 100644 tests/sozialhilfe.test.ts create mode 100644 tests/vertraege.test.ts diff --git a/.gitignore b/.gitignore index 5f6ac50..77b9218 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ test-results/ Thumbs.db *.log npm-debug.log* + +# Bildschirmfotos entstehen beim Entwickeln neu +screenshots/ +daten/dokumente/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3e33e74 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,93 @@ +# ============================================================================= +# Betreuungs-Cockpit - Produktionsabbild +# ============================================================================= +# +# Drei Stufen, damit im laufenden Abbild weder Quelltext noch Bauwerkzeuge +# liegen. Das Ergebnis ist rund 400 MB - der groesste Anteil davon sind +# Tesseract und die deutschen Sprachdaten fuer die Texterkennung. +# +# Basis ist Alpine: kleiner Angriffsflaeche, und @node-rs/argon2 sowie +# Prisma bringen fertige musl-Binaerdateien mit. +# ============================================================================= + +# --- Stufe 1: Abhaengigkeiten ----------------------------------------------- +FROM node:22-alpine AS abhaengigkeiten +WORKDIR /app + +# Prisma und OpenSSL gehoeren zusammen. +RUN apk add --no-cache libc6-compat openssl + +COPY package.json package-lock.json ./ +COPY prisma ./prisma + +# "npm ci" mit dem Lockfile - reproduzierbar, keine Ueberraschungen beim +# naechsten Bau. Der postinstall-Schritt erzeugt den Prisma-Client. +RUN npm ci --ignore-scripts \ + && npx prisma generate + +# --- Stufe 2: Bauen ---------------------------------------------------------- +FROM node:22-alpine AS bau +WORKDIR /app + +RUN apk add --no-cache libc6-compat openssl + +COPY --from=abhaengigkeiten /app/node_modules ./node_modules +COPY . . + +# Beim Bauen werden keine echten Geheimnisse gebraucht: src/lib/env.ts +# prueft erst beim Zugriff. Diese Platzhalter erfuellen nur die Typen. +ENV NEXT_TELEMETRY_DISABLED=1 +ENV SKIP_ENV_VALIDATION=1 + +RUN npm run build + +# --- Stufe 3: Laufzeit ------------------------------------------------------- +FROM node:22-alpine AS laufzeit +WORKDIR /app + +# tesseract-ocr + deutsche Sprachdaten fuer die Texterkennung, +# poppler-utils fuer pdftoppm (PDF-Seiten zu Bildern), +# tzdata fuer Europe/Berlin - sonst rechnet der Server in UTC und +# verschiebt Fristen um einen Tag. +RUN apk add --no-cache \ + libc6-compat \ + openssl \ + tzdata \ + tesseract-ocr \ + tesseract-ocr-data-deu \ + poppler-utils \ + && cp /usr/share/zoneinfo/Europe/Berlin /etc/localtime \ + && echo "Europe/Berlin" > /etc/timezone + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TZ=Europe/Berlin +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# Nicht als root laufen. +RUN addgroup --system --gid 1001 cockpit \ + && adduser --system --uid 1001 --ingroup cockpit cockpit + +# Der Standalone-Build von Next.js bringt nur mit, was wirklich gebraucht wird. +COPY --from=bau --chown=cockpit:cockpit /app/.next/standalone ./ +COPY --from=bau --chown=cockpit:cockpit /app/.next/static ./.next/static +COPY --from=bau --chown=cockpit:cockpit /app/public ./public + +# Prisma-Schema, Migrationen und SQL-Skripte fuer "prisma migrate deploy" +# im laufenden Behaelter. +COPY --from=bau --chown=cockpit:cockpit /app/prisma ./prisma +COPY --from=bau --chown=cockpit:cockpit /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=bau --chown=cockpit:cockpit /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=bau --chown=cockpit:cockpit /app/node_modules/prisma ./node_modules/prisma + +# Ablage fuer den Dateitreiber, falls kein Objektspeicher benutzt wird. +RUN mkdir -p /app/daten/dokumente && chown -R cockpit:cockpit /app/daten + +USER cockpit +EXPOSE 3000 + +# Migrationen laufen beim Start, danach die Anwendung. Bewusst hier und +# nicht in einem eigenen Behaelter: so kann die Datenbank nie hinter dem +# Programmstand zurueckbleiben. +CMD ["sh", "-c", "node node_modules/prisma/build/index.js migrate deploy && node server.js"] diff --git a/README.md b/README.md index e32d9ad..ec3aa79 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,523 @@ # Betreuungs-Cockpit Arbeits- und Dokumentationswerkzeug für die ehrenamtliche rechtliche Betreuung -eines Angehörigen — Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe. +eines Angehörigen: Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe, Berichte. -> Die Anwendung erteilt **keine Rechtsberatung**. Alle Hinweise mit -> Paragraphen-Bezug ersetzen nicht die Rückfrage bei Betreuungsgericht, -> Sozialhilfeträger oder Anwalt. +> **Keine Rechtsberatung.** Die Anwendung rechnet Fristen aus, sortiert +> Unterlagen und erzeugt Berichte. Sie kennt den Einzelfall nicht. Jeder +> Hinweis mit Paragraphen-Bezug trägt den sichtbaren Vermerk, dass er die +> Rückfrage bei Betreuungsgericht, Sozialhilfeträger oder Anwalt **nicht** +> ersetzt. Wer sich darauf verlässt, statt beim Rechtspfleger nachzufragen, +> kann Geld und Ansprüche verlieren. -Die vollständige Installations-, Betriebs- und Backup-Anleitung folgt in -diesem Dokument, sobald Etappe 1 steht. +--- + +## Inhalt + +- [Was die Anwendung kann](#was-die-anwendung-kann) +- [Schnellstart zum Ausprobieren](#schnellstart-zum-ausprobieren) +- [Produktivbetrieb mit Docker Compose](#produktivbetrieb-mit-docker-compose) +- [Reverse Proxy](#reverse-proxy) +- [Alternative: Vercel und Neon](#alternative-vercel-und-neon) +- [Sicherheit und Datenschutz](#sicherheit-und-datenschutz) +- [Was rechtlich zu tun ist](#was-rechtlich-zu-tun-ist) +- [Sicherung und Wiederherstellung](#sicherung-und-wiederherstellung) +- [Schlüsselverwaltung](#schlüsselverwaltung) +- [Entwicklung](#entwicklung) +- [Bauprotokoll](#bauprotokoll) + +--- + +## Was die Anwendung kann + +| Bereich | Kurz gesagt | +|---|---| +| **Heute** | Eine Seite, die morgens die Frage beantwortet, was ansteht. Fristen-Ampel, wer am Zug ist, offene Nachforderungen des Bezirks, ungeprüfter Posteingang, Kennzahlen. | +| **Fristen** | Die gesetzlichen Pflichtfristen entstehen von selbst aus dem Bestellungsdatum. Liste, Kalender, ICS-Feed zum Abonnieren. | +| **Vorgänge** | Fallakte je Angelegenheit, mit Vorlagen (Heimeinzug, Hausverkauf, Widerspruch …) und der festen Ablaufkette für genehmigungspflichtige Geschäfte. | +| **Dokumente** | Foto oder Scan hochladen, Texterkennung, Volltextsuche über alle Schreiben, Posteingangsbuch, Belegmappe als eine PDF. | +| **Vermögen & Kasse** | Kassenbuch mit Belegzwang, Konten, Vermögensposten mit Wert-Historie, Vermögensverzeichnis und Rechnungslegung als PDF. | +| **Sozialhilfe** | Antragsakte, Nachforderungs-Tracker, Bescheid-Prüfung mit Rechtsbehelfsfrist, Heimkosten-Gegenüberstellung, Schonvermögens-Bewertung. | +| **Immobilie** | Stammdaten, Belastungen, Wert-Historie, Status im Sozialhilfeverfahren, Pflichten-Checkliste bei Leerstand. | +| **Verträge** | Versicherungen und laufende Verträge mit Kündigungsfristen, Rückkaufswert-Anfrage, Kündigungs-Assistent. | +| **Heim & Pflege** | Heimvertrag, Leistungszuschläge, Pflegegrad-Historie, Gesundheitsverlauf mit COPD-Bezug. | +| **Kontakte** | Verzeichnis aller Stellen, Ein-Klick-Kontaktprotokoll, Auswertung für den Jahresbericht. | +| **Berichte** | Vermögensverzeichnis, Jahresbericht, Rechnungslegung, Belegmappe, Unterlagenpaket für den Bezirk, Vollexport. | + +--- + +## Schnellstart zum Ausprobieren + +Voraussetzungen: **Node.js ≥ 20.11**, **Docker** und **Git**. + +```bash +git clone https://git.wilde-server.de/florian.wilde/betreuungs-cockpit.git +cd betreuungs-cockpit +npm install +``` + +**1. Datenbank und Objektspeicher starten.** Beide horchen nur auf +`127.0.0.1`, gehen also nicht ins Netz hinaus: + +```bash +docker compose -f docker-compose.dev.yml up -d +``` + +**2. Konfiguration anlegen.** Die Geheimnisse erzeugst du dir: + +```bash +cp .env.example .env +npm run schluessel # gibt AUTH_SECRET, ENC_KEY_V1, INDEX_KEY aus +``` + +Trage die ausgegebenen Werte in die `.env` ein und setze: + +``` +DATABASE_URL="postgresql://cockpit:entwicklung@localhost:5433/betreuungs_cockpit?schema=public" +``` + +**3. Datenbank einrichten:** + +```bash +npx prisma migrate dev --name fundament +npm run db:sql # Volltextsuche und Audit-Schutz +npm run db:seed +``` + +**4. Starten:** + +```bash +npm run dev +``` + +Dann [http://localhost:3000](http://localhost:3000) öffnen. Beim ersten Aufruf +führt dich ein Assistent in drei Schritten durch Zugang, zweiten Faktor und +die Stammdaten der betreuten Person. + +### Mit Beispieldaten ansehen + +Um zu sehen, wie die Anwendung mit Inhalt aussieht: + +```bash +echo "SEED_DEMO=true" >> .env +npm run db:seed +``` + +Das legt den Fall „Erika Musterfrau" an — COPD GOLD III, Pflegegrad 3, +Heimeinzug Januar 2026, leerstehendes Haus in Bindlach, zwei Versicherungen, +laufender Antrag beim Bezirk Oberfranken mit acht Nachforderungen. +**Der Demofall wird in der Produktion nicht angelegt** und lässt sich später +über den Papierkorb vollständig entfernen. + +--- + +## Produktivbetrieb mit Docker Compose + +Das Compose-Setup bringt drei Dienste mit: die Anwendung, PostgreSQL und +MinIO als S3-kompatiblen Objektspeicher, dazu einen Sicherungsdienst. + +**Einen Reverse Proxy enthält es bewusst nicht** — er ist auf dem Zielserver +bereits vorhanden. Die Anwendung horcht nur auf `127.0.0.1:3000`. +**Die Datenbank hat keine Portfreigabe** und ist ausschließlich über das +interne Docker-Netz erreichbar. + +```bash +# 1. Konfiguration +cp .env.example .env +npm run schluessel + +# In der .env mindestens setzen: +# POSTGRES_PASSWORD, AUTH_SECRET, AUTH_URL (die echte öffentliche Adresse), +# ENC_KEY_V1, INDEX_KEY, +# S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, SICHERUNG_PASSWORT + +# 2. Bauen und starten +docker compose up -d --build + +# 3. Datenbank vorbereiten +# (die Migrationen laufen beim Start der Anwendung automatisch; +# die beiden SQL-Skripte müssen einmalig dazu) +docker compose exec app node node_modules/prisma/build/index.js db execute \ + --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql +docker compose exec app node node_modules/prisma/build/index.js db execute \ + --schema prisma/schema.prisma --file prisma/sql/audit-schutz.sql + +# 4. Bucket im Objektspeicher anlegen +docker compose exec minio mc alias set lokal http://localhost:9000 \ + "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY" +docker compose exec minio mc mb lokal/betreuungs-cockpit +``` + +Danach im Browser über die eigene Domain auf `/ersteinrichtung` gehen und den +ersten Zugang anlegen. Die Seite sperrt sich selbst, sobald ein Zugang +existiert. + +### Aktualisieren + +```bash +git pull +docker compose up -d --build +``` + +Die Migrationen laufen beim Start des Anwendungsbehälters mit. **Vorher ein +Backup anstoßen** (siehe unten) — eine Migration lässt sich nicht zurückrollen. + +--- + +## Reverse Proxy + +Die Anwendung erwartet, dass der Proxy TLS beendet und die echte Client-IP +weiterreicht. Sie liest `X-Forwarded-For` bzw. `X-Real-IP` für das +Rate-Limiting am Login und für das Audit-Log (dort nur als Hash). + +**Caddy** + +```caddyfile +cockpit.example.de { + encode zstd gzip + reverse_proxy 127.0.0.1:3000 + + # Uploads: Fotos von Behördenschreiben können groß sein. + request_body { + max_size 30MB + } +} +``` + +**nginx** + +```nginx +server { + listen 443 ssl http2; + server_name cockpit.example.de; + + ssl_certificate /etc/letsencrypt/live/cockpit.example.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cockpit.example.de/privkey.pem; + + client_max_body_size 30M; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +**Traefik** (Labels am `app`-Dienst) + +```yaml +labels: + - traefik.enable=true + - traefik.http.routers.cockpit.rule=Host(`cockpit.example.de`) + - traefik.http.routers.cockpit.tls.certresolver=letsencrypt + - traefik.http.services.cockpit.loadbalancer.server.port=3000 +``` + +In allen Fällen muss `AUTH_URL` in der `.env` auf die öffentliche Adresse +zeigen (`https://cockpit.example.de`), sonst schlägt die Anmeldung fehl. + +--- + +## Alternative: Vercel und Neon + +Technisch möglich, hier aber **nicht empfohlen**. Der Grund ist nicht +technischer Natur: + +> Vercel Inc. und Neon Inc. sind US-Unternehmen. Auch bei Wahl einer +> EU-Region unterliegen sie potenziell dem US-CLOUD-Act. Für +> Gesundheitsdaten einer nicht selbst entscheidungsfähigen Person — also +> besondere Kategorien nach Art. 9 DSGVO — ist das eine Abwägung, die man +> bewusst treffen und dokumentieren muss. Ein eigener Server in Deutschland +> nimmt einem diese Abwägung ab. + +Wenn du es dennoch willst: + +1. **Neon**-Projekt in der Region `eu-central-1` (Frankfurt) anlegen, + Verbindungszeichenfolge als `DATABASE_URL` hinterlegen. +2. In **Vercel** unter *Settings → Functions* die Region `fra1` festlegen. +3. Alle Umgebungsvariablen aus `.env.example` als Vercel-Secrets eintragen. + `ENC_KEY_V1` und `INDEX_KEY` gehören zwingend dazu. +4. **Objektspeicher**: nicht Vercel Blob, sondern ein EU-Anbieter, z. B. + Hetzner Object Storage (Falkenstein oder Nürnberg). `SPEICHER_TREIBER=s3`. +5. **OCR und PDF laufen nicht auf Vercel.** Tesseract braucht ein System- + Binary, das es in der Serverless-Umgebung nicht gibt. Setze `OCR_AKTIV=false` + oder betreibe einen kleinen Worker daneben. PDF-Erzeugung mit + `@react-pdf/renderer` funktioniert, kann bei großen Belegmappen aber ins + Zeitlimit laufen. +6. **Migrationen** laufen nicht automatisch: `npx prisma migrate deploy` als + Build-Schritt oder von Hand. +7. Mit **beiden** Anbietern ist ein Auftragsverarbeitungsvertrag nach + Art. 28 DSGVO abzuschließen. + +--- + +## Sicherheit und Datenschutz + +Hier liegen Gesundheits- und Finanzdaten einer Person, die nicht selbst +entscheiden kann. Was dafür umgesetzt ist: + +**Verschlüsselung** + +- Transport über TLS (durch den Reverse Proxy). +- Zusätzlich **anwendungsseitige Feldverschlüsselung** mit AES-256-GCM für + Gesundheitsdaten, Diagnosen, den ermittelten Willen, IBANs, + Versicherungsnummern, Renten- und Steuernummern sowie alle Freitext-Notizen. + Der Schlüssel liegt in der Umgebung, nicht in der Datenbank — ein + Datenbank-Dump allein nützt niemandem etwas. +- Der Feldname wird mitauthentifiziert: ein Geheimtext lässt sich nicht von + „Diagnosen" in „Notizen" umhängen. +- **Blindindex** (HMAC-SHA256 mit eigenem Schlüssel) für die IBAN, damit die + Frage „kenne ich dieses Konto schon?" beantwortbar bleibt, ohne sie im + Klartext zu speichern. + +**Zugang** + +- Passwörter mit **Argon2id** (19 MiB, t=2, p=1 — OWASP-Empfehlung). +- **Zweiter Faktor ist Pflicht** und lässt sich nicht abschalten. TOTP nach + RFC 6238, kompatibel mit Aegis, 2FAS, Bitwarden, 1Password. +- **Rate-Limiting** am Login, nach E-Mail *und* nach Anschluss. Die Sperre + wird geprüft, bevor Argon2 überhaupt rechnet. +- Die Antwortzeit ist auf eine Mindestdauer gestreckt, damit sie nicht + verrät, welche E-Mail-Adressen existieren. +- **Sitzungsablauf** nach 30 Minuten Leerlauf. Für Export, endgültiges + Löschen und die Benutzerverwaltung ist eine **erneute Anmeldung** nötig. +- Passwortwechsel beendet **alle** offenen Sitzungen, auch auf fremden + Geräten. + +**Rollen** + +- `betreuer` — voller Zugriff auf die zugeordneten Betreuten. +- `mitleser` — nur lesend, und nur die ausdrücklich je Modul freigegebenen + Bereiche (z. B. Geschwister, die den Kontaktverlauf sehen dürfen, aber + nicht die Finanzen). +- `admin` — Benutzerverwaltung. + +Geprüft wird **immer serverseitig gegen die Betreuten-ID**, nie nur im UI +ausgeblendet. + +**Audit-Log** + +Jede schreibende Aktion und jeder Dokumentzugriff wird protokolliert: wer, +wann, was, alter Wert → neuer Wert. Die Änderungen liegen verschlüsselt. + +Das Log ist **unveränderlich**: ein Datenbank-Trigger weist UPDATE und DELETE +ab — auch bei direktem Zugriff mit `psql`. Damit ist es der Nachweis +gegenüber Betreuungsgericht und möglichen Miterben. Nachprüfen lässt sich das +so: + +```bash +docker compose exec db psql -U cockpit betreuungs_cockpit \ + -c "UPDATE audit_log SET entitaet='x';" +# ERROR: Das Audit-Log ist unveraenderlich. UPDATE auf audit_log ist nicht zulaessig. +``` + +**Content-Security-Policy** + +Streng gesetzt, mit einer Nonce pro Anfrage. Keine externen Schriften, kein +CDN, keine Analytics, keine Tracker. Der QR-Code für den zweiten Faktor wird +serverseitig erzeugt und als Data-URI eingebettet, nicht von einem fremden +Dienst geholt. + +**Uploads** + +Der Dateityp wird an den **Magic Bytes** geprüft, nicht an der Endung und +nicht am gemeldeten MIME-Typ. Erlaubt sind PDF, JPEG, PNG, WEBP, TIFF, HEIC. +Ausgeliefert wird nur über kurzlebige signierte URLs, immer mit +`Content-Disposition: attachment` und `X-Content-Type-Options: nosniff` — +ein hochgeladenes HTML-Dokument kann also nie als Seite ausgeführt werden. + +**Löschen** + +Papierkorb mit 30 Tagen, danach echtes Löschen. Endgültiges Löschen erfordert +eine frische Anmeldung. + +--- + +## Was rechtlich zu tun ist + +Das erledigt keine Software für dich: + +1. **Auftragsverarbeitungsvertrag nach Art. 28 DSGVO** mit dem Hoster und mit + dem Betreiber des Objektspeichers. Bei Hetzner findest du ihn im + Kundenkonto unter *Verträge → AV-Vertrag*, bei Netcup und IONOS ähnlich. + Ohne AV-Vertrag ist der Betrieb formal rechtswidrig. +2. **Verarbeitungsverzeichnis** nach Art. 30 DSGVO. Ein Textbaustein liegt in + [`docs/verarbeitungsverzeichnis.md`](docs/verarbeitungsverzeichnis.md) und + muss nur ergänzt werden. +3. **Datenschutzhinweise** für die betreute Person und für Mitleser: + [`docs/datenschutzhinweise.md`](docs/datenschutzhinweise.md). +4. **Server in Deutschland oder mindestens in der EU.** Keine US-Dienste im + Datenpfad. +5. Bei einer Datenpanne: **Meldepflicht binnen 72 Stunden** (Art. 33 DSGVO) + an die zuständige Aufsichtsbehörde — in Bayern das BayLDA. + +--- + +## Sicherung und Wiederherstellung + +### Was automatisch läuft + +Der Dienst `sicherung` legt **täglich um 03:15 Uhr** einen Stand nach +`./backups`: + +- `pg_dump` im eigenen Format, damit auch einzelne Tabellen zurückholbar sind +- verschlüsselt mit AES-256 und PBKDF2 aus `SICHERUNG_PASSWORT` +- SHA-256-Prüfsumme daneben +- **Selbstprüfung**: direkt nach dem Schreiben wird geprüft, ob sich die + Datei wieder entschlüsseln lässt. Schlägt das fehl, endet der Lauf mit + Fehler statt mit einem stillen Scheinerfolg. +- Aufbewahrung 30 Tage, der jeweils erste Stand eines Monats bleibt dauerhaft + +Von Hand anstoßen: + +```bash +docker compose exec sicherung /bin/sh /sicherung.sh +``` + +### Zurückspielen + +```bash +docker compose exec sicherung /bin/sh /wiederherstellung.sh \ + /backups/cockpit_2026-08-22_0315.dump.enc +``` + +Das Skript prüft die Prüfsumme, fragt nach und spielt zurück. Danach: + +```bash +docker compose exec app npm run db:sql # Trigger und Indizes erneut anlegen +``` + +### Wiederherstellung üben + +**Ein Backup, das nie zurückgespielt wurde, ist kein Backup.** Nimm dir +einmal im Jahr zwanzig Minuten: + +```bash +# 1. Testdatenbank neben der echten anlegen +docker compose exec db createdb -U cockpit probe + +# 2. Sicherung dorthin zurückspielen +docker compose exec -e PGDATABASE=probe -e SICHERUNG_OHNE_RUECKFRAGE=ja \ + sicherung /bin/sh /wiederherstellung.sh /backups/cockpit_.dump.enc + +# 3. Stichprobe: sind die Daten da? +docker compose exec db psql -U cockpit probe \ + -c "SELECT vorname, nachname FROM betreute;" \ + -c "SELECT count(*) FROM audit_log;" + +# 4. Aufräumen +docker compose exec db dropdb -U cockpit probe +``` + +Wichtig ist der letzte Schritt in der Anwendung selbst: eine betreute Person +öffnen und nachsehen, ob die **Diagnosen im Klartext** erscheinen. Erst dann +weißt du, dass nicht nur die Datenbank, sondern auch der Schlüssel stimmt. + +### Was das Backup NICHT enthält + +**Den Verschlüsselungsschlüssel.** `ENC_KEY_V1` liegt in der `.env`, nicht in +der Datenbank. Das ist Absicht: ein gestohlenes Backup ist damit wertlos. +Für dich bedeutet es aber: **ohne diesen Schlüssel ist auch dein eigenes +Backup halb blind.** Diagnosen, IBANs, Versicherungsnummern und Notizen +bleiben unlesbar. + +Ebenfalls nicht enthalten: die **Dateien im Objektspeicher**. Die sichere +gesondert, zum Beispiel: + +```bash +docker compose exec minio mc mirror lokal/betreuungs-cockpit /backups/dokumente +``` + +Oder du benutzt regelmäßig den **Vollexport** in der Anwendung — der packt +Dateien, Daten und Berichte in ein ZIP. + +--- + +## Schlüsselverwaltung + +`ENC_KEY_V1` ist der wichtigste Wert der ganzen Installation. + +**Sichere ihn getrennt vom Backup.** Passwortmanager *und* ausgedruckt bei +den Betreuungsunterlagen. Wenn er verloren geht, sind die verschlüsselten +Felder unwiederbringlich weg — es gibt keine Hintertür. + +### Schlüssel wechseln + +Die Anwendung kann mehrere Schlüsselversionen gleichzeitig lesen: + +```bash +# 1. Neuen Schlüssel erzeugen und als ENC_KEY_V2 in die .env eintragen +npm run schluessel + +# 2. Umschalten - ab jetzt wird mit V2 verschlüsselt +ENC_KEY_AKTIV=2 + +# 3. Neu starten +docker compose up -d +``` + +**`ENC_KEY_V1` bleibt in der `.env` stehen**, solange noch Daten damit +verschlüsselt sind — sonst sind ältere Datensätze nicht mehr lesbar. Erst +wenn alle Felder auf die neue Version umgeschrieben wurden, darf der alte +Schlüssel weg. + +--- + +## Entwicklung + +```bash +npm run dev # Entwicklungsserver +npm run typecheck # TypeScript ohne Ausgabe prüfen +npm run lint +npm test # Vitest: Fachlogik +npm run test:e2e # Playwright: kritische Abläufe +npm run db:studio # Prisma Studio +npm run protokoll # Bauprotokoll aus der Git-Historie +``` + +Konventionen — verbindlich für alle Beiträge: +[`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md). +Schnittstellen zwischen den Modulen: +[`docs/SCHNITTSTELLEN.md`](docs/SCHNITTSTELLEN.md). + +### Bildschirmfotos erzeugen + +```bash +node scripts/screenshots.mjs /heute /fristen /vorgaenge +``` + +Meldet sich mit dem Entwicklungszugang an und fotografiert die Seiten am +Schreibtisch und am Handy, hell und dunkel. Setzt voraus, dass +`SEED_DEV_TOTP_SECRET` in der `.env` steht — das gilt nur außerhalb der +Produktion. + +### Technische Grundlage + +Next.js 15 (App Router) · TypeScript · Tailwind CSS · shadcn/ui-Bausteine · +PostgreSQL 17 mit Prisma · Auth.js · Zod · Server Actions · +`@react-pdf/renderer` · Tesseract (deutsch) · Vitest · Playwright + +Bewusste Abweichungen von shadcn/ui im Original: Bedienelemente sind 44 px +hoch statt 36 px, Auswahlfelder benutzen das native ` + + {zustand.fehler && ( + + + )} + + + + + + + + + + + + ) +} diff --git a/src/app/(app)/bestaetigen/page.tsx b/src/app/(app)/bestaetigen/page.tsx new file mode 100644 index 0000000..a92fde9 --- /dev/null +++ b/src/app/(app)/bestaetigen/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from 'next' +import { ShieldAlert } from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { erfordereBenutzer } from '@/lib/auth/sitzung' +import { BestaetigenFormular } from './formular' + +export const metadata: Metadata = { title: 'Bestätigen' } +export const dynamic = 'force-dynamic' + +export default async function BestaetigenSeite({ + searchParams, +}: { + searchParams: Promise<{ weiter?: string }> +}) { + await erfordereBenutzer() + const { weiter = '/heute' } = await searchParams + + return ( +
+ + + Kurz bestätigen, bitte + + Für diesen Schritt brauchst du deine Zugangsdaten noch einmal. + + + + + + + + + +
+ ) +} diff --git a/src/app/(app)/dokumente/neu/page.tsx b/src/app/(app)/dokumente/neu/page.tsx new file mode 100644 index 0000000..afcfd11 --- /dev/null +++ b/src/app/(app)/dokumente/neu/page.tsx @@ -0,0 +1,69 @@ +import { redirect } from 'next/navigation' +import { ShieldCheck } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { SeitenKopf } from '@/components/seiten-kopf' +import { UploadFormular } from '@/components/dokumente/upload-formular' +import { dokumentenkontext, vorgaengeDerAkte } from '@/lib/dokumente/kontext' +import { ZugriffVerweigert } from '@/lib/auth/sitzung' +import { env } from '@/lib/env' +import { formatBytes } from '@/lib/speicher/dateityp' + +export const metadata = { title: 'Dokument aufnehmen' } +export const dynamic = 'force-dynamic' + +/** + * Post aufnehmen. + * + * Der wichtigste Weg durch diese Anwendung: am Heimbett stehen, einen + * Bescheid mit dem Handy fotografieren, ihn dem Vorgang zuordnen und die + * Widerspruchsfrist daraus erzeugen. Alles auf einer Seite, ohne + * Zwischenschritte. + */ +export default async function NeuesDokumentSeite({ + searchParams, +}: { + searchParams: Promise> +}) { + const parameter = await searchParams + + let kontext + try { + kontext = await dokumentenkontext('schreiben') + } catch (fehler) { + if (fehler instanceof ZugriffVerweigert) redirect('/dokumente') + throw fehler + } + + const vorgaenge = await vorgaengeDerAkte(kontext.betreuteId) + + const einzeln = (name: string) => { + const wert = parameter[name] + return Array.isArray(wert) ? wert[0] : wert + } + + return ( +
+ + + + + + +
+ ) +} diff --git a/src/app/(app)/dokumente/page.tsx b/src/app/(app)/dokumente/page.tsx new file mode 100644 index 0000000..83cd101 --- /dev/null +++ b/src/app/(app)/dokumente/page.tsx @@ -0,0 +1,184 @@ +import Link from 'next/link' +import { BookOpen, FileText, Inbox, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { DokumentListe } from '@/components/dokumente/dokument-liste' +import { FilterLeiste } from '@/components/dokumente/filter-leiste' +import { BelegmappeFormular } from '@/components/dokumente/belegmappe-formular' +import { dokumentenkontext, tagsDerAkte, vorgaengeDerAkte } from '@/lib/dokumente/kontext' +import { dokumenteListe } from '@/lib/dokumente/suche' +import { filterAusParametern } from '@/lib/validierung/dokumente' +import { db, nichtGeloescht } from '@/lib/db' + +export const metadata = { title: 'Dokumente' } +export const dynamic = 'force-dynamic' + +/** + * Die Dokumentenliste mit Volltextsuche und Filtern. + * + * Die Suche geht ueber Titel, Absender, Aktenzeichen, Dateiname UND den + * per OCR erkannten Text - man findet einen Bescheid also auch dann, wenn + * man sich nur noch an eine Formulierung darin erinnert. + */ +export default async function DokumenteSeite({ + searchParams, +}: { + searchParams: Promise> +}) { + const parameter = await searchParams + const { betreuteId, darfSchreiben } = await dokumentenkontext('lesen') + + const filter = filterAusParametern(parameter) + const proSeite = 25 + + const [{ eintraege, gesamt }, tags, vorgaenge, neuImPosteingang] = await Promise.all([ + dokumenteListe(betreuteId, { ...filter, proSeite }), + tagsDerAkte(betreuteId), + vorgaengeDerAkte(betreuteId), + db.dokument.count({ where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht } }), + ]) + + const seiten = Math.max(1, Math.ceil(gesamt / proSeite)) + const gefiltert = Boolean( + filter.suche || filter.art || filter.richtung || filter.posteingangStatus || filter.von || filter.bis || filter.tagId, + ) + + return ( +
+ + + {darfSchreiben('DOKUMENTE') && ( + + )} + + } + /> + + + + {eintraege.length === 0 ? ( + + +
+ ) +} + +function einzelnerParameter(wert: string | string[] | undefined): string | null { + const roh = Array.isArray(wert) ? wert[0] : wert + return roh && roh.trim() !== '' ? roh : null +} + +function Blaettern({ + aktuelleSeite, + seiten, + parameter, +}: { + aktuelleSeite: number + seiten: number + parameter: Record +}) { + const adresse = (seite: number) => { + const suche = new URLSearchParams() + for (const [name, wert] of Object.entries(parameter)) { + const roh = Array.isArray(wert) ? wert[0] : wert + if (roh && name !== 'seite') suche.set(name, roh) + } + suche.set('seite', String(seite)) + return `/dokumente?${suche.toString()}` + } + + return ( + + ) +} diff --git a/src/app/(app)/dokumente/posteingang/page.tsx b/src/app/(app)/dokumente/posteingang/page.tsx new file mode 100644 index 0000000..4926f2a --- /dev/null +++ b/src/app/(app)/dokumente/posteingang/page.tsx @@ -0,0 +1,143 @@ +import Link from 'next/link' +import { Inbox, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { ArtAbzeichen, OcrAbzeichen, RichtungAbzeichen } from '@/components/dokumente/abzeichen' +import { PosteingangStufen } from '@/components/dokumente/posteingang-stufen' +import { dokumentenkontext } from '@/lib/dokumente/kontext' +import { db, nichtGeloescht } from '@/lib/db' +import { formatDatum } from '@/lib/utils' +import { POSTEINGANG_BESCHRIFTUNG, POSTEINGANG_ERKLAERUNG, POSTEINGANG_STUFEN } from '@/lib/validierung/dokumente' + +export const metadata = { title: 'Posteingang' } +export const dynamic = 'force-dynamic' + +/** + * Das Posteingangsbuch. + * + * Vier Spalten, vier Stufen: NEU -> GESICHTET -> ZUGEORDNET -> + * BEANTWORTET. Das ist der digitale Nachbau des Poststapels auf dem + * Kuechentisch - mit dem Unterschied, dass hier nichts unter den Stapel + * rutschen kann. + */ +export default async function PosteingangSeite() { + const { betreuteId, darfSchreiben } = await dokumentenkontext('lesen') + const schreibrecht = darfSchreiben('DOKUMENTE') + + const dokumente = await db.dokument.findMany({ + where: { betreuteId, ...nichtGeloescht }, + select: { + id: true, + titel: true, + art: true, + richtung: true, + absender: true, + eingangAm: true, + schreibenVom: true, + posteingangStatus: true, + ocrStatus: true, + _count: { select: { verknuepfungen: true } }, + }, + orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }], + take: 400, + }) + + const nachStufe = Object.fromEntries( + POSTEINGANG_STUFEN.map((stufe) => [stufe, dokumente.filter((d) => d.posteingangStatus === stufe)]), + ) as Record<(typeof POSTEINGANG_STUFEN)[number], typeof dokumente> + + return ( +
+ + +
+ ) +} diff --git a/src/app/(app)/finanzen/buchungen/neu/page.tsx b/src/app/(app)/finanzen/buchungen/neu/page.tsx new file mode 100644 index 0000000..3c285f4 --- /dev/null +++ b/src/app/(app)/finanzen/buchungen/neu/page.tsx @@ -0,0 +1,103 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { AlertCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Buchungsformular } from '@/components/finanzen/buchungs-formular' +import { db, nichtGeloescht } from '@/lib/db' +import { finanzKontext } from '@/lib/finanzen/seite' +import { heute, tagAlsText } from '@/lib/finanzen/datum' + +export const metadata = { title: 'Buchung erfassen' } + +/** + * Buchung erfassen. + * + * Mit ?schnell=1 bleibt das Formular nach dem Speichern offen – für den + * Abend, an dem ein ganzer Kontoauszug nachgetragen wird. + */ +export default async function BuchungNeuSeite({ + searchParams, +}: { + searchParams: Promise> +}) { + const parameter = await searchParams + const { betreute, darfSchreiben } = await finanzKontext() + if (!darfSchreiben('FINANZEN')) notFound() + + const schnell = parameter.schnell === '1' + const kontoId = typeof parameter.kontoId === 'string' ? parameter.kontoId : null + + const [konten, belege] = await Promise.all([ + db.konto.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + select: { id: true, bezeichnung: true, istBetreuerkonto: true }, + orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }], + }), + db.dokument.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + select: { id: true, titel: true }, + orderBy: { erstelltAm: 'desc' }, + take: 200, + }), + ]) + + return ( +
+ + + {schnell ? 'Zur normalen Erfassung' : 'Zur Schnellerfassung'} + + + } + /> + + {konten.length === 0 && ( + + + )} + + k.id === kontoId), + ...konten.filter((k) => k.id !== kontoId), + ] + : konten + } + belege={belege} + schnellerfassung={schnell} + standardDatum={tagAlsText(heute())} + /> + + +
+ ) +} diff --git a/src/app/(app)/finanzen/buchungen/page.tsx b/src/app/(app)/finanzen/buchungen/page.tsx new file mode 100644 index 0000000..5a912aa --- /dev/null +++ b/src/app/(app)/finanzen/buchungen/page.tsx @@ -0,0 +1,286 @@ +import Link from 'next/link' +import { Banknote, Plus, Upload } from 'lucide-react' +import type { Prisma } from '@prisma/client' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Belegzeichen, Betrag, Kennzahl, Vermischungswarnung } from '@/components/finanzen/bausteine' +import { Buchungsfilter } from '@/components/finanzen/buchungs-filter' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { formatCent, formatDatum } from '@/lib/utils' +import { finanzKontext } from '@/lib/finanzen/seite' +import { belegFehlt, kennzahlen, vermischungsWarnung, type BuchungEingabe } from '@/lib/finanzen/kassenbuch' +import { KATEGORIE_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' +import { buchungsfilterSchema } from '@/lib/validierung/finanzen' + +export const metadata = { title: 'Kassenbuch' } + +const HOECHSTZAHL = 300 + +/** + * Das Kassenbuch. + * + * Zwei Dinge sind hier absichtlich auffällig: Buchungen ohne Beleg und + * ohne Begründung werden rot markiert, und eine Buchung, die auf einem + * Konto des Betreuers läuft und trotzdem als Vermögen der Betreuten + * gilt, bekommt eine eigene Warnung. Beides sind die Punkte, an denen + * eine Rechnungslegung beim Gericht auseinanderfällt. + */ +export default async function BuchungenSeite({ + searchParams, +}: { + searchParams: Promise> +}) { + const roh = await searchParams + const { betreute, darfSchreiben } = await finanzKontext() + const schreiben = darfSchreiben('FINANZEN') + + const flach: Record = {} + for (const [schluessel, wert] of Object.entries(roh)) { + if (typeof wert === 'string') flach[schluessel] = wert + } + const gepruft = buchungsfilterSchema.safeParse(flach) + const filter = gepruft.success + ? gepruft.data + : { + kontoId: null, + kategorie: null, + richtung: null, + von: null, + bis: null, + suche: null, + nurOhneBeleg: false, + nurEigeneAuslagen: false, + } + + const bedingung: Prisma.BuchungWhereInput = { + betreuteId: betreute.id, + ...nichtGeloescht, + ...(filter.kontoId ? { kontoId: filter.kontoId } : {}), + ...(filter.kategorie ? { kategorie: filter.kategorie } : {}), + ...(filter.richtung ? { richtung: filter.richtung } : {}), + ...(filter.von || filter.bis + ? { + datum: { + ...(filter.von ? { gte: filter.von } : {}), + ...(filter.bis ? { lte: filter.bis } : {}), + }, + } + : {}), + // Der Verwendungszweck ist verschluesselt und laesst sich nicht + // durchsuchen - gesucht wird deshalb nur in der Gegenstelle. + ...(filter.suche ? { gegenstelle: { contains: filter.suche, mode: 'insensitive' } } : {}), + ...(filter.nurEigeneAuslagen ? { betrifftBetreute: false } : {}), + ...(filter.nurOhneBeleg + ? { belegId: null, OR: [{ belegFehltBegruendung: null }, { belegFehltBegruendung: '' }] } + : {}), + } + + const [zeilen, gesamt, konten] = await Promise.all([ + db.buchung.findMany({ + where: bedingung, + include: { + konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } }, + beleg: { select: { id: true, titel: true } }, + }, + orderBy: [{ datum: 'desc' }, { erstelltAm: 'desc' }], + take: HOECHSTZAHL, + }), + db.buchung.count({ where: bedingung }), + db.konto.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + select: { id: true, bezeichnung: true }, + orderBy: { bezeichnung: 'asc' }, + }), + ]) + + const buchungen: BuchungEingabe[] = zeilen.map((zeile) => { + const offen = oeffnen('Buchung', zeile) + return { + id: zeile.id, + datum: zeile.datum, + betragCent: zeile.betragCent, + richtung: zeile.richtung, + kategorie: zeile.kategorie, + gegenstelle: zeile.gegenstelle, + verwendungszweck: offen.verwendungszweck, + kontoId: zeile.kontoId, + kontoBezeichnung: zeile.konto?.bezeichnung ?? null, + belegId: zeile.belegId, + belegTitel: zeile.beleg?.titel ?? null, + belegFehltBegruendung: zeile.belegFehltBegruendung, + betrifftBetreute: zeile.betrifftBetreute, + vermoegenswirksam: zeile.vermoegenswirksam, + aufBetreuerkonto: zeile.konto?.istBetreuerkonto ?? false, + } + }) + + const zahlen = kennzahlen(buchungen) + + return ( +
+ + + + + ) : undefined + } + /> + + + +
+ + + + 0 + ? 'Genau danach fragt der Rechtspfleger.' + : 'Da fehlt nichts.' + } + ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'} + /> +
+ + {zahlen.vermischungen > 0 && } + + {buchungen.length === 0 ? ( + + +
+ ) +} diff --git a/src/app/(app)/finanzen/konten/[id]/page.tsx b/src/app/(app)/finanzen/konten/[id]/page.tsx new file mode 100644 index 0000000..0ec8409 --- /dev/null +++ b/src/app/(app)/finanzen/konten/[id]/page.tsx @@ -0,0 +1,245 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { ArrowRight } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Betrag } from '@/components/finanzen/bausteine' +import { Kontoformular } from '@/components/finanzen/konto-formular' +import { Saldoformular } from '@/components/finanzen/saldo-formular' +import { Loeschknopf } from '@/components/finanzen/aktionen' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { formatDatum } from '@/lib/utils' +import { finanzKontext } from '@/lib/finanzen/seite' +import { heute, tagAlsText } from '@/lib/finanzen/datum' +import { ibanFormatieren } from '@/lib/finanzen/iban' +import { KONTO_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' +import { kontoLoeschen, saldostandLoeschen } from '@/server/actions/finanzen' + +export const metadata = { title: 'Konto' } + +/** + * Ein einzelnes Konto: Stammdaten, Historie der Kontostände und die + * letzten Buchungen. Kontostände werden hier angelegt und nie + * überschrieben – jede alte Aufstellung bleibt reproduzierbar. + */ +export default async function KontoSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const { betreute, darfSchreiben } = await finanzKontext() + const schreiben = darfSchreiben('FINANZEN') + + const konto = await db.konto.findFirst({ + where: { id, betreuteId: betreute.id, ...nichtGeloescht }, + include: { + saldostaende: { orderBy: { stichtag: 'desc' } }, + _count: { select: { buchungen: true } }, + }, + }) + if (!konto) notFound() + + const offen = oeffnen('Konto', konto) + + const buchungen = await db.buchung.findMany({ + where: { kontoId: konto.id, ...nichtGeloescht }, + orderBy: { datum: 'desc' }, + take: 10, + select: { + id: true, + datum: true, + betragCent: true, + richtung: true, + kategorie: true, + gegenstelle: true, + }, + }) + + return ( +
+ + + {KONTO_ART_BESCHRIFTUNG[konto.art]} + {konto.bank ? ` · ${konto.bank}` : ''} + + {offen.iban && ( + {ibanFormatieren(offen.iban)} + )} + + } + pfad={[ + { titel: 'Vermögen & Kasse', href: '/finanzen' }, + { titel: 'Konten', href: '/finanzen/konten' }, + { titel: konto.bezeichnung }, + ]} + aktionen={ + + } + /> + + {konto.istBetreuerkonto && ( + + Konto des Betreuers – gehört nicht zum Vermögen der betreuten Person + + )} + + {/* ------------------------------------------------------------- */} + + + Kontostände + + Ein Stand gilt als Bestand am Ende seines Stichtags. Stände werden nie überschrieben – + so lässt sich eine abgegebene Vermögensübersicht Jahre später exakt nachbilden. + + + + {konto.saldostaende.length === 0 ? ( +

+ Noch kein Kontostand erfasst. Ohne Stand bleibt dieses Konto in der Rechnungslegung + mit 0,00 € stehen – und die Rechenprobe geht nicht auf. +

+ ) : ( + + + + Stichtag + Stand + Quelle + {schreiben && Aktion} + + + + {konto.saldostaende.map((stand) => ( + + + {formatDatum(stand.stichtag)} + + + + + + {stand.quelle ?? 'keine Quelle angegeben'} + + {schreiben && ( + + + + )} + + ))} + +
+ )} + + {schreiben && ( +
+

Neuen Kontostand festhalten

+ +
+ )} +
+
+ + {/* ------------------------------------------------------------- */} + + + Letzte Buchungen + + {konto._count.buchungen} Buchung(en) insgesamt auf diesem Konto. + + + + {buchungen.length === 0 ? ( +

Noch keine Buchungen auf diesem Konto.

+ ) : ( + + + + Datum + Gegenstelle + Betrag + + + + {buchungen.map((buchung) => ( + + + {formatDatum(buchung.datum)} + + + + {buchung.gegenstelle ?? 'ohne Gegenstelle'} + + + + + + + ))} + +
+ )} +
+
+ + {/* ------------------------------------------------------------- */} + {schreiben && ( + + + Stammdaten ändern + + + + +
+ +
+
+
+ )} + + +
+ ) +} diff --git a/src/app/(app)/finanzen/konten/neu/page.tsx b/src/app/(app)/finanzen/konten/neu/page.tsx new file mode 100644 index 0000000..d9ab8b6 --- /dev/null +++ b/src/app/(app)/finanzen/konten/neu/page.tsx @@ -0,0 +1,30 @@ +import { notFound } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Kontoformular } from '@/components/finanzen/konto-formular' +import { finanzKontext } from '@/lib/finanzen/seite' + +export const metadata = { title: 'Konto anlegen' } + +export default async function KontoNeuSeite() { + const { betreute, darfSchreiben } = await finanzKontext() + if (!darfSchreiben('FINANZEN')) notFound() + + return ( +
+ + + + + +
+ ) +} diff --git a/src/app/(app)/finanzen/konten/page.tsx b/src/app/(app)/finanzen/konten/page.tsx new file mode 100644 index 0000000..b041150 --- /dev/null +++ b/src/app/(app)/finanzen/konten/page.tsx @@ -0,0 +1,123 @@ +import Link from 'next/link' +import { Landmark, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf' +import { Betrag } from '@/components/finanzen/bausteine' +import { centZuZahl } from '@/lib/db' +import { formatCent, formatDatum } from '@/lib/utils' +import { finanzKontext } from '@/lib/finanzen/seite' +import { heute } from '@/lib/finanzen/datum' +import { kontenLaden, kontostaendeZumStichtag } from '@/lib/berichte/gemeinsam' + +export const metadata = { title: 'Konten' } + +export default async function KontenSeite() { + const { betreute, darfSchreiben } = await finanzKontext() + const schreiben = darfSchreiben('FINANZEN') + const stichtag = heute() + + const konten = await kontenLaden(betreute.id) + const staende = kontostaendeZumStichtag(konten, stichtag) + const summeCent = staende + .filter((s) => !s.istBetreuerkonto) + .reduce((s, z) => s + (z.betragCent ?? 0n), 0n) + + return ( +
+ + +
+ ) +} diff --git a/src/app/(app)/finanzen/page.tsx b/src/app/(app)/finanzen/page.tsx new file mode 100644 index 0000000..7b97c63 --- /dev/null +++ b/src/app/(app)/finanzen/page.tsx @@ -0,0 +1,318 @@ +import Link from 'next/link' +import { ArrowRight, Banknote, FileDown, Landmark, Plus, Upload } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { + Belegzeichen, + Betrag, + Hinweisliste, + Kennzahl, + Vermischungswarnung, +} from '@/components/finanzen/bausteine' +import { centZuZahl } from '@/lib/db' +import { formatCent, formatDatum } from '@/lib/utils' +import { finanzKontext } from '@/lib/finanzen/seite' +import { heute, monatePlus, tagePlus } from '@/lib/finanzen/datum' +import { kennzahlen, vermischungsWarnung } from '@/lib/finanzen/kassenbuch' +import { KATEGORIE_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' +import { laufendesBetreuungsjahr } from '@/lib/finanzen/betreuungsjahr' +import { buchungenLaden, kontenLaden, kontostaendeZumStichtag, vermoegenLaden } from '@/lib/berichte/gemeinsam' + +export const metadata = { title: 'Vermögen & Kasse' } + +/** + * Übersichtsseite des Finanzmoduls. + * + * Sie beantwortet die vier Fragen, die man sich beim Öffnen stellt: + * Was ist da? Wo liegt es? Was ist zuletzt passiert? Und: Wo fehlt noch + * ein Beleg? + */ +export default async function FinanzenSeite() { + const { betreute, darfSchreiben } = await finanzKontext() + const schreiben = darfSchreiben('FINANZEN') + const stichtag = heute() + + const [konten, buchungen, vermoegen] = await Promise.all([ + kontenLaden(betreute.id), + buchungenLaden(betreute.id), + vermoegenLaden(betreute.id, stichtag), + ]) + + const eigeneKonten = konten.filter((k) => !k.istBetreuerkonto) + const staende = kontostaendeZumStichtag(eigeneKonten, stichtag) + const summeKontenCent = staende.reduce((s, z) => s + (z.betragCent ?? 0n), 0n) + + const zwoelfMonate = tagePlus(monatePlus(stichtag, -12), 1) + const letzteZwoelf = buchungen.filter((b) => b.datum >= zwoelfMonate) + const zahlen = kennzahlen(letzteZwoelf) + + const letzte = [...buchungen].sort((a, b) => b.datum.getTime() - a.datum.getTime()).slice(0, 8) + + const jahr = betreute.bestellungAm ? laufendesBetreuungsjahr(betreute.bestellungAm) : null + + const hinweise: string[] = [] + for (const stand of staende) { + if (stand.betragCent === null) { + hinweise.push(`Für „${stand.bezeichnung}“ ist noch kein Kontostand erfasst.`) + } else if (stand.stichtag && stand.stichtag < monatePlus(stichtag, -3)) { + hinweise.push( + `Der Stand von „${stand.bezeichnung}“ ist vom ${formatDatum(stand.stichtag)} und damit ` + + 'älter als drei Monate.', + ) + } + } + if (!betreute.bestellungAm) { + hinweise.push( + 'In den Stammdaten fehlt das Datum der Bestellung. Ohne dieses Datum lässt sich das ' + + 'Betreuungsjahr nicht bestimmen – und damit auch keine Rechnungslegung erzeugen.', + ) + } + + return ( +
+ + + + + ) : undefined + } + /> + +
+ + + 0 + ? `${zahlen.beleg.ohneNachweis} Buchung(en) ohne Beleg und ohne Begründung` + : 'Alle Buchungen sind belegt oder begründet' + } + ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'} + href="/finanzen/buchungen?nurOhneBeleg=on" + /> + +
+ + {zahlen.vermischungen > 0 && } + + + + {/* ------------------------------------------------------------- */} + + + Konten + + Stand zum {formatDatum(stichtag)} – jeweils der jüngste erfasste Saldostand. + + + + {konten.length === 0 ? ( + + + + + + {/* ------------------------------------------------------------- */} + + +
+ Zuletzt gebucht + Die acht jüngsten Einträge im Kassenbuch. +
+
+ {schreiben && ( + + )} + +
+
+ + {letzte.length === 0 ? ( + + + +
+ ) : undefined + } + /> + ) : ( + + + + Datum + Vorgang + Beleg + Betrag + + + + {letzte.map((buchung) => ( + + + {formatDatum(buchung.datum)} + + + + {buchung.gegenstelle ?? KATEGORIE_BESCHRIFTUNG[buchung.kategorie]} + + + {KATEGORIE_BESCHRIFTUNG[buchung.kategorie]} + {!buchung.betrifftBetreute && ' · eigene Auslage'} + {buchung.vermoegenswirksam && ' · vermögenswirksam'} + + {vermischungsWarnung(buchung) && ( + + + + )} + + + + + + + + + ))} + +
+ )} + + + + + + ) +} diff --git a/src/app/(app)/hinweise/page.tsx b/src/app/(app)/hinweise/page.tsx new file mode 100644 index 0000000..ede3054 --- /dev/null +++ b/src/app/(app)/hinweise/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from 'next' +import { AlertTriangle } from 'lucide-react' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { HinweisSuche } from '@/components/hinweise/hinweis-suche' +import { hinweiseLaden } from '@/lib/hinweise/laden' + +export const metadata: Metadata = { title: 'Hinweise' } + +/** + * Alle Hinweiskarten auf einen Blick. + * + * Zweck der Seite: nachschlagen, wenn man weiss, dass es zu einem Thema + * eine Regel gab, aber nicht mehr welche. Im Alltag begegnen einem die + * Karten dort, wo sie gebraucht werden - hier stehen sie alle. + */ +export default function HinweiseSeite() { + const { karten, fehler } = hinweiseLaden() + + return ( +
+ + Kurze Merkzettel zu den Regeln, an denen in der rechtlichen Betreuung am häufigsten + etwas schiefgeht – Genehmigungen, Ausschlussfristen, geschütztes Vermögen. Jede Karte + nennt die Rechtsgrundlage und verlinkt den Gesetzestext. + + } + /> + + {fehler.length > 0 && ( + + + )} + + + + +
+ ) +} diff --git a/src/app/(app)/immobilie/[id]/bearbeiten/page.tsx b/src/app/(app)/immobilie/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..3586cfe --- /dev/null +++ b/src/app/(app)/immobilie/[id]/bearbeiten/page.tsx @@ -0,0 +1,67 @@ +import { notFound, redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { ImmobilieFormular } from '@/components/immobilie/immobilie-formular' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { alsIsoTag } from '@/lib/fristen/datum' + +export const dynamic = 'force-dynamic' + +export default async function ImmobilieBearbeiten({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/immobilie/${id}/bearbeiten`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen') + if (!darfSchreiben('IMMOBILIE')) redirect(`/immobilie/${id}`) + + const immobilie = await db.immobilie.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + }) + if (!immobilie) notFound() + + const offen = oeffnen('Immobilie', immobilie) + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/immobilie/[id]/checkliste/page.tsx b/src/app/(app)/immobilie/[id]/checkliste/page.tsx new file mode 100644 index 0000000..025c50a --- /dev/null +++ b/src/app/(app)/immobilie/[id]/checkliste/page.tsx @@ -0,0 +1,95 @@ +import { notFound } from 'next/navigation' +import { ShieldAlert } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Checkliste } from '@/components/immobilie/checkliste' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { heuteAlsTag, tageZwischen } from '@/lib/fristen/datum' +import { formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +export default async function LeerstandsCheckliste({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/immobilie/${id}/checkliste`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen') + const schreiben = darfSchreiben('IMMOBILIE') + + const immobilie = await db.immobilie.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + include: { + checklistenPunkte: { orderBy: [{ reihenfolge: 'asc' }, { titel: 'asc' }] }, + }, + }) + if (!immobilie) notFound() + + const heute = heuteAlsTag() + + return ( +
+ + + + + + ({ + id: punkt.id, + titel: punkt.titel, + beschreibung: punkt.beschreibung, + erledigt: punkt.erledigt, + erledigtAm: punkt.erledigtAm?.toISOString() ?? null, + faelligAm: punkt.faelligAm?.toISOString() ?? null, + wiederholungRrule: punkt.wiederholungRrule, + tageBisFaellig: punkt.faelligAm ? tageZwischen(heute, punkt.faelligAm) : null, + }))} + /> + + +

+ Kurz übersetzt: Die Verkehrssicherungspflicht (§ 823 BGB) trifft den Eigentümer, auch + wenn niemand im Haus wohnt — für den Sturz auf dem ungeräumten Gehweg haftest du als + Vertreter mit dem Vermögen der betreuten Person. § 23 VVG verpflichtet dazu, eine + Gefahrerhöhung wie den Leerstand dem Versicherer anzuzeigen. Und § 1821 BGB verlangt, + das Vermögen der betreuten Person zu erhalten — ein Haus, das über einen Winter + durchfriert, ist das Gegenteil davon. +

+
+ ) +} diff --git a/src/app/(app)/immobilie/[id]/inventar/page.tsx b/src/app/(app)/immobilie/[id]/inventar/page.tsx new file mode 100644 index 0000000..c7d8ee9 --- /dev/null +++ b/src/app/(app)/immobilie/[id]/inventar/page.tsx @@ -0,0 +1,90 @@ +import { notFound } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Inventar } from '@/components/immobilie/inventar' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { inventarNotizLesen, inventarSumme } from '@/lib/immobilie/inventar' + +export const dynamic = 'force-dynamic' + +export default async function InventarSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/immobilie/${id}/inventar`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen') + const schreiben = darfSchreiben('IMMOBILIE') + + const immobilie = await db.immobilie.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + select: { id: true, bezeichnung: true }, + }) + if (!immobilie) notFound() + + const posten = await db.vermoegensposten.findMany({ + where: { + betreuteId: aktive.id, + immobilieId: id, + art: 'WERTGEGENSTAND', + ...nichtGeloescht, + }, + include: { + wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 }, + verknuepfungen: { + include: { + dokument: { select: { id: true, titel: true, art: true, schreibenVom: true } }, + }, + }, + }, + orderBy: { bezeichnung: 'asc' }, + }) + + const stuecke = posten.map((p) => { + const offen = oeffnen('Vermoegensposten', p) + const notiz = inventarNotizLesen(offen.notizen) + return { + id: p.id, + bezeichnung: p.bezeichnung, + raum: notiz.raum, + verbleib: notiz.verbleib, + bemerkung: notiz.bemerkung, + wertCent: centZuZahl(p.wertstaende[0]?.wertCent ?? null), + fotos: p.verknuepfungen + .filter((v) => v.dokument.art === 'FOTO') + .map((v) => ({ + id: v.dokument.id, + titel: v.dokument.titel, + aufgenommenAm: v.dokument.schreibenVom?.toISOString() ?? null, + })), + } + }) + + const summeCent = centZuZahl( + inventarSumme(posten.map((p) => ({ wertCent: p.wertstaende[0]?.wertCent ?? null }))), + ) + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/immobilie/[id]/page.tsx b/src/app/(app)/immobilie/[id]/page.tsx new file mode 100644 index 0000000..b974776 --- /dev/null +++ b/src/app/(app)/immobilie/[id]/page.tsx @@ -0,0 +1,225 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { Building2, ClipboardList, Package, Pencil } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Datenzeile } from '@/components/substanz-formular' +import { Belastungen } from '@/components/immobilie/belastungen' +import { Kostenkonto } from '@/components/immobilie/kostenkonto' +import { StatusSozialhilfe } from '@/components/immobilie/status-sozialhilfe' +import { WertHistorie } from '@/components/immobilie/wert-historie' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { heuteAlsTag, tageZwischen } from '@/lib/fristen/datum' +import { + durchschnittlicheJahreskosten, + kostenkontoAufbauen, +} from '@/lib/immobilie/kostenkonto' +import { IMMOBILIEN_KATEGORIEN } from '@/lib/immobilie/konstanten' +import { formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +export default async function ImmobilieDetail({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/immobilie/${id}`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen') + const schreiben = darfSchreiben('IMMOBILIE') + + const immobilie = await db.immobilie.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + include: { + belastungen: { orderBy: [{ abteilung: 'asc' }, { laufendeNr: 'asc' }] }, + checklistenPunkte: true, + vermoegensposten: { + where: { art: 'IMMOBILIE', ...nichtGeloescht }, + include: { wertstaende: { orderBy: { stichtag: 'desc' } } }, + }, + }, + }) + if (!immobilie) notFound() + + const [buchungen, anzahlObjekte] = await Promise.all([ + db.buchung.findMany({ + where: { + betreuteId: aktive.id, + kategorie: { in: IMMOBILIEN_KATEGORIEN }, + ...nichtGeloescht, + }, + select: { datum: true, betragCent: true, richtung: true, kategorie: true }, + orderBy: { datum: 'desc' }, + }), + db.immobilie.count({ where: { betreuteId: aktive.id, ...nichtGeloescht } }), + ]) + + const offen = oeffnen('Immobilie', immobilie) + const jahre = kostenkontoAufbauen(buchungen) + const laufendesJahr = heuteAlsTag().getUTCFullYear() + const heute = heuteAlsTag() + + const wertstaende = immobilie.vermoegensposten[0]?.wertstaende ?? [] + const offenePunkte = immobilie.checklistenPunkte.filter((p) => !p.erledigt) + const ueberfaellig = offenePunkte.filter( + (p) => p.faelligAm && tageZwischen(heute, p.faelligAm) < 0, + ) + + const anschrift = + [immobilie.strasse, [immobilie.plz, immobilie.ort].filter(Boolean).join(' ')] + .filter(Boolean) + .join(', ') || 'Anschrift nicht erfasst' + + return ( +
+ + + + {schreiben && ( + + )} +
+ } + /> + + + + + + + Die Angaben, nach denen jede Behörde und jeder Notar als Erstes fragt. + + + +
+ {immobilie.grundbuchamt ?? '–'} + {immobilie.grundbuchBlatt ?? '–'} + {immobilie.gemarkung ?? '–'} + {immobilie.flurstueck ?? '–'} + + {immobilie.eigentumsanteil ?? '–'} + +
+
+ + {immobilie.wohnflaecheQm ? `${immobilie.wohnflaecheQm} m²` : '–'} + + + {immobilie.grundstueckQm ? `${immobilie.grundstueckQm} m²` : '–'} + + {immobilie.baujahr ?? '–'} + + {immobilie.bewohntStatus ? ( + + {immobilie.bewohntStatus} + + ) : ( + '–' + )} + + + {immobilie.leerstandSeit ? formatDatum(immobilie.leerstandSeit) : '–'} + +
+ + {offen.notizen && ( +
+

{offen.notizen}

+
+ )} +
+
+ + ({ + id: b.id, + art: b.art, + abteilung: b.abteilung, + laufendeNr: b.laufendeNr, + glaeubiger: b.glaeubiger, + betragCent: centZuZahl(b.betragCent), + bemerkung: b.bemerkung, + geloeschtImGrundbuchAm: b.geloeschtImGrundbuchAm?.toISOString() ?? null, + }))} + /> + + ({ + id: w.id, + stichtag: w.stichtag.toISOString(), + wertCent: centZuZahl(w.wertCent) ?? 0, + wertquelle: w.wertquelle, + bemerkung: w.bemerkung, + }))} + /> + + + + 1} + durchschnittCent={centZuZahl(durchschnittlicheJahreskosten(jahre, laufendesJahr)) ?? 0} + jahre={jahre.map((jahr) => ({ + jahr: jahr.jahr, + anzahlBuchungen: jahr.anzahlBuchungen, + ausgabenCent: centZuZahl(jahr.ausgabenCent) ?? 0, + einnahmenCent: centZuZahl(jahr.einnahmenCent) ?? 0, + saldoCent: centZuZahl(jahr.saldoCent) ?? 0, + ausgaben: jahr.ausgaben.map((z) => ({ + kategorie: z.kategorie, + summeCent: centZuZahl(z.summeCent) ?? 0, + anzahl: z.anzahl, + })), + einnahmen: jahr.einnahmen.map((z) => ({ + kategorie: z.kategorie, + summeCent: centZuZahl(z.summeCent) ?? 0, + anzahl: z.anzahl, + })), + }))} + /> + + ) +} diff --git a/src/app/(app)/immobilie/neu/page.tsx b/src/app/(app)/immobilie/neu/page.tsx new file mode 100644 index 0000000..140e106 --- /dev/null +++ b/src/app/(app)/immobilie/neu/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { ImmobilieFormular, LEERE_IMMOBILIE } from '@/components/immobilie/immobilie-formular' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' + +export const dynamic = 'force-dynamic' + +export default async function ImmobilieAnlegen() { + const benutzer = await erfordereBenutzer('/immobilie/neu') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen') + if (!darfSchreiben('IMMOBILIE')) redirect('/immobilie') + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/immobilie/page.tsx b/src/app/(app)/immobilie/page.tsx new file mode 100644 index 0000000..f701541 --- /dev/null +++ b/src/app/(app)/immobilie/page.tsx @@ -0,0 +1,167 @@ +import Link from 'next/link' +import { Building2, Plus, TriangleAlert } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { heuteAlsTag, tageZwischen } from '@/lib/fristen/datum' +import { + IMMOBILIEN_STATUS_BESCHRIFTUNG, + IMMOBILIEN_STATUS_RECHTSGRUNDLAGE, +} from '@/lib/immobilie/konstanten' +import { formatCent, formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +export default async function ImmobilienUebersicht() { + const benutzer = await erfordereBenutzer('/immobilie') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'IMMOBILIE', 'lesen') + const schreiben = darfSchreiben('IMMOBILIE') + + const immobilien = await db.immobilie.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + include: { + belastungen: true, + checklistenPunkte: true, + vermoegensposten: { + where: { art: 'IMMOBILIE', ...nichtGeloescht }, + include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, + }, + }, + orderBy: { erstelltAm: 'asc' }, + }) + + const heute = heuteAlsTag() + + return ( +
+ + +
+ ) +} diff --git a/src/app/(app)/konto/page.tsx b/src/app/(app)/konto/page.tsx new file mode 100644 index 0000000..e863cce --- /dev/null +++ b/src/app/(app)/konto/page.tsx @@ -0,0 +1,109 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { KeyRound, ShieldCheck } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { SeitenKopf } from '@/components/seiten-kopf' +import { erfordereBenutzer } from '@/lib/auth/sitzung' +import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen' +import { formatDatumZeit } from '@/lib/utils' +import { PasswortFormular } from './passwort-formular' + +export const metadata: Metadata = { title: 'Mein Konto' } +export const dynamic = 'force-dynamic' + +export default async function KontoSeite() { + const benutzer = await erfordereBenutzer() + const codesUebrig = benutzer.wiederherstellungscodes.length + + return ( +
+ + + + + Zugang + + +
+
+
Name
+
{benutzer.name}
+
+
+
E-Mail
+
{benutzer.email}
+
+
+
Rolle
+
+ {ROLLEN_BESCHRIFTUNG[benutzer.rolle]} +

+ {ROLLEN_ERKLAERUNG[benutzer.rolle]} +

+
+
+
+
Letzte Anmeldung
+
{formatDatumZeit(benutzer.letzteAnmeldung)}
+
+
+
+
+ + + + + + + {benutzer.totpAktiviertAm + ? `Eingerichtet am ${formatDatumZeit(benutzer.totpAktiviertAm)}.` + : 'Noch nicht eingerichtet.'} + + + +

+ {codesUebrig > 0 ? ( + <> + Noch {codesUebrig} Wiederherstellungscode + {codesUebrig === 1 ? '' : 's'} übrig. + {codesUebrig <= 2 && ( + + Es wird Zeit, neue zu erzeugen. + + )} + + ) : ( + + Keine Wiederherstellungscodes mehr übrig. Geht dein Handy verloren, kommst du + nicht mehr an die Daten. Bitte gleich neue erzeugen. + + )} +

+ +
+
+ + + + + + + Nach dem Ändern werden alle anderen offenen Sitzungen beendet — auch auf Geräten, an + denen du gerade nicht sitzt. + + + + + + +
+ ) +} diff --git a/src/app/(app)/konto/passwort-formular.tsx b/src/app/(app)/konto/passwort-formular.tsx new file mode 100644 index 0000000..20b4d02 --- /dev/null +++ b/src/app/(app)/konto/passwort-formular.tsx @@ -0,0 +1,72 @@ +'use client' + +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react' +import { passwortAendern, type PasswortZustand } from '@/server/actions/konto' +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 ( + + ) +} + +export function PasswortFormular() { + const [zustand, aktion] = useActionState(passwortAendern, {}) + + if (zustand.erfolg) { + return ( + + + ) + } + + return ( +
+ {zustand.fehler && ( + + + )} + + + + + + + + + + + + + + + + ) +} diff --git a/src/app/(app)/konto/zwei-faktor/neue-codes.tsx b/src/app/(app)/konto/zwei-faktor/neue-codes.tsx new file mode 100644 index 0000000..8768349 --- /dev/null +++ b/src/app/(app)/konto/zwei-faktor/neue-codes.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useState, useTransition } from 'react' +import { Check, Copy, Loader2, RefreshCw } from 'lucide-react' +import { wiederherstellungscodesNeu } from '@/server/actions/zwei-faktor' +import { Button } from '@/components/ui/button' + +/** + * Neue Wiederherstellungscodes erzeugen. + * + * Die Aktion verlangt eine frische Anmeldung. Ist die zu lange her, + * leitet der Server zur Bestaetigungsseite um - das passiert im + * Hintergrund und braucht hier keine Behandlung. + */ +export function NeueCodes() { + const [codes, setCodes] = useState(null) + const [kopiert, setKopiert] = useState(false) + const [laeuft, starten] = useTransition() + + if (codes) { + return ( +
+
    + {codes.map((code) => ( +
  • + {code} +
  • + ))} +
+

+ Diese Liste wird dir nie wieder angezeigt. Drucke sie aus und lege sie zu deinen + Betreuungsunterlagen — nicht auf dasselbe Gerät wie die Authenticator-App. +

+
+ + +
+
+ ) + } + + return ( + + ) +} diff --git a/src/app/(app)/konto/zwei-faktor/page.tsx b/src/app/(app)/konto/zwei-faktor/page.tsx new file mode 100644 index 0000000..220112d --- /dev/null +++ b/src/app/(app)/konto/zwei-faktor/page.tsx @@ -0,0 +1,77 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { KeyRound, ShieldCheck } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { SeitenKopf } from '@/components/seiten-kopf' +import { erfordereBenutzer } from '@/lib/auth/sitzung' +import { formatDatumZeit } from '@/lib/utils' +import { NeueCodes } from './neue-codes' + +export const metadata: Metadata = { title: 'Zweiter Faktor' } +export const dynamic = 'force-dynamic' + +export default async function ZweiFaktorSeite() { + const benutzer = await erfordereBenutzer() + if (!benutzer.totpAktiviertAm) redirect('/zwei-faktor/einrichten') + + const uebrig = benutzer.wiederherstellungscodes.length + + return ( +
+ + + + + + + + + + + Der einzige Weg zurück, wenn das Handy mit der App verloren geht. Jeder Code + funktioniert genau einmal. + + + +

+ Noch {uebrig} von ursprünglich 8 Codes + übrig. + {uebrig <= 2 && uebrig > 0 && ( + + Erzeuge besser neue, bevor der letzte aufgebraucht ist. + + )} + {uebrig === 0 && ( + + Ohne Codes bist du bei Verlust des Handys ausgesperrt. + + )} +

+ + + + Neue Codes machen die alten sofort ungültig. Für diesen Schritt brauchst du Passwort + und Code noch einmal. + + + + +
+
+
+ ) +} diff --git a/src/app/(app)/sozialhilfe/[antragId]/bearbeiten/page.tsx b/src/app/(app)/sozialhilfe/[antragId]/bearbeiten/page.tsx new file mode 100644 index 0000000..42312f6 --- /dev/null +++ b/src/app/(app)/sozialhilfe/[antragId]/bearbeiten/page.tsx @@ -0,0 +1,60 @@ +import { notFound, redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { AntragFormular } from '@/components/sozialhilfe/antrag-formular' +import { oeffnen } from '@/lib/crypto/feld' +import { db, nichtGeloescht } from '@/lib/db' +import { alsIsoTag } from '@/lib/fristen/datum' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' + +export const dynamic = 'force-dynamic' + +export default async function AntragBearbeitenSeite({ + params, +}: { + params: Promise<{ antragId: string }> +}) { + const { antragId } = await params + const { betreuteId, darfSchreiben } = await sozialhilfeKontext('schreiben') + if (!darfSchreiben) redirect(`/sozialhilfe/${antragId}`) + + const antrag = await db.sozialhilfeAntrag.findFirst({ + where: { id: antragId, betreuteId, ...nichtGeloescht }, + }) + if (!antrag) notFound() + + const offen = oeffnen('SozialhilfeAntrag', antrag) + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/[antragId]/bescheide/[bescheidId]/page.tsx b/src/app/(app)/sozialhilfe/[antragId]/bescheide/[bescheidId]/page.tsx new file mode 100644 index 0000000..a40197b --- /dev/null +++ b/src/app/(app)/sozialhilfe/[antragId]/bescheide/[bescheidId]/page.tsx @@ -0,0 +1,149 @@ +import { notFound } from 'next/navigation' +import { Trash2 } from 'lucide-react' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine' +import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular' +import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung' +import { oeffnen } from '@/lib/crypto/feld' +import { db, nichtGeloescht } from '@/lib/db' +import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' +import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide' +import { centZuEingabe } from '@/lib/sozialhilfe/betrag' +import { + fristAmpel, + rechtsbehelfsfristBerechnen, + zugangsvermutung, +} from '@/lib/sozialhilfe/rechtsbehelf' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' +import { bescheidLoeschen } from '@/server/actions/sozialhilfe' +import { formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +/** + * Bescheid-Prüfung. + * + * Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die + * Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen + * können, wo die Rechnung abgebogen ist. + */ +export default async function BescheidSeite({ + params, +}: { + params: Promise<{ antragId: string; bescheidId: string }> +}) { + const { antragId, bescheidId } = await params + const { betreuteId, darfSchreiben } = await sozialhilfeKontext() + + const bescheid = await db.bescheid.findFirst({ + where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht }, + include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } }, + }) + if (!bescheid) notFound() + + const offen = oeffnen('Bescheid', bescheid) + const heute = heuteAlsTag() + + const ergebnis = rechtsbehelfsfristBerechnen({ + zugegangenAm: bescheid.zugegangenAm, + bescheidVom: bescheid.bescheidVom, + fristMonate: bescheid.rechtsbehelfsfristMonate, + heute, + }) + + const vorschlag = zugangsvermutung(bescheid.bescheidVom) + + return ( +
+ + {bescheid.antrag.traeger} + {bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '} + {formatDatum(bescheid.zugegangenAm)} + + } + pfad={[ + { titel: 'Start', href: '/heute' }, + { titel: 'Sozialhilfe', href: '/sozialhilfe' }, + { titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` }, + { titel: 'Bescheid' }, + ]} + /> + + + + {offen.notizen && ( + + + Notizen + Verschlüsselt gespeichert. + + +

{offen.notizen}

+
+
+ )} + + {darfSchreiben && ( +
+

Bescheid bearbeiten

+ + +
+ + +
+
+ )} +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/[antragId]/bescheide/neu/page.tsx b/src/app/(app)/sozialhilfe/[antragId]/bescheide/neu/page.tsx new file mode 100644 index 0000000..d1581d6 --- /dev/null +++ b/src/app/(app)/sozialhilfe/[antragId]/bescheide/neu/page.tsx @@ -0,0 +1,47 @@ +import { notFound, redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { BescheidFormular, LEERER_BESCHEID } from '@/components/sozialhilfe/bescheid-formular' +import { db, nichtGeloescht } from '@/lib/db' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' + +export const dynamic = 'force-dynamic' + +/** + * Bescheid erfassen. + * + * Der Zugangsvorschlag nach § 37 Abs. 2 SGB X wird erst angeboten, wenn + * ein Bescheiddatum feststeht - und auch dann nur als Vorschlag. Wer den + * Umschlag mit dem Poststempel aufhebt, ist im Streitfall besser dran. + */ +export default async function NeuerBescheidSeite({ + params, +}: { + params: Promise<{ antragId: string }> +}) { + const { antragId } = await params + const { betreuteId, darfSchreiben } = await sozialhilfeKontext('schreiben') + if (!darfSchreiben) redirect(`/sozialhilfe/${antragId}`) + + const antrag = await db.sozialhilfeAntrag.findFirst({ + where: { id: antragId, betreuteId, ...nichtGeloescht }, + select: { id: true, leistungsart: true, traeger: true }, + }) + if (!antrag) notFound() + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/[antragId]/page.tsx b/src/app/(app)/sozialhilfe/[antragId]/page.tsx new file mode 100644 index 0000000..761b293 --- /dev/null +++ b/src/app/(app)/sozialhilfe/[antragId]/page.tsx @@ -0,0 +1,262 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { FileStack, Pencil, Plus, Trash2 } from 'lucide-react' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine' +import { BescheidAbzeichen } from '@/components/sozialhilfe/abzeichen' +import { ChecklistePanel } from '@/components/sozialhilfe/checkliste-panel' +import { NachforderungTracker } from '@/components/sozialhilfe/nachforderung-tracker' +import { db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' +import { naechsterSchritt } from '@/lib/sozialhilfe/bescheide' +import { nochNichtUebernommen } from '@/lib/sozialhilfe/checkliste' +import { fristAmpel, rechtsbehelfsfristBerechnen } from '@/lib/sozialhilfe/rechtsbehelf' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' +import { antragLoeschen } from '@/server/actions/sozialhilfe' +import { formatCent, formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +/** + * Die Antragsakte: Stammdaten, Nachforderungs-Tracker, Bescheide. + * + * Alles, was zu einem Verfahren gehört, auf einer Seite - damit man vor + * dem Anruf beim Sachbearbeiter nicht drei Ansichten durchklicken muss. + */ +export default async function AntragsakteSeite({ + params, +}: { + params: Promise<{ antragId: string }> +}) { + const { antragId } = await params + const { betreuteId, darfSchreiben } = await sozialhilfeKontext() + + const antrag = await db.sozialhilfeAntrag.findFirst({ + where: { id: antragId, betreuteId, ...nichtGeloescht }, + include: { + nachforderungen: { + where: nichtGeloescht, + orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }], + include: { _count: { select: { verknuepfungen: true } } }, + }, + bescheide: { where: nichtGeloescht, orderBy: { bescheidVom: 'desc' } }, + }, + }) + if (!antrag) notFound() + + const offen = oeffnen('SozialhilfeAntrag', antrag) + const heute = heuteAlsTag() + + const offeneChecklistenpositionen = nochNichtUebernommen( + antrag.nachforderungen.map((n) => n.bezeichnung), + ) + + return ( +
+ + {antrag.traeger} + {antrag.aktenzeichen ? ` · Aktenzeichen ${antrag.aktenzeichen}` : ''} + {antrag.sachbearbeiter ? ` · ${antrag.sachbearbeiter}` : ''} + {antrag.telefon ? ` · ${antrag.telefon}` : ''} + + } + pfad={[ + { titel: 'Start', href: '/heute' }, + { titel: 'Sozialhilfe', href: '/sozialhilfe' }, + { titel: 'Antragsakte' }, + ]} + aktionen={ + darfSchreiben && ( + <> + + + + ) + } + /> + + + + Verfahrensstand + + +
+
+
Antrag gestellt
+
{formatDatum(antrag.antragAm)}
+
+
+
Eingang beim Träger
+
{formatDatum(antrag.eingangBeimTraegerAm)}
+
+
+
Bewilligt von
+
{formatDatum(antrag.bewilligtVon)}
+
+
+
Bewilligt bis
+
{formatDatum(antrag.bewilligtBis)}
+
+
+ + {antrag.email && ( +

+ E-Mail: + + {antrag.email} + +

+ )} + + {offen.notizen && ( +
+

+ Notizen +

+

{offen.notizen}

+
+ )} + +

+ Leistungen setzen ein, sobald der Träger von der Notlage Kenntnis hat — nicht erst mit + der Bewilligung. Deshalb ist das Eingangsdatum so wichtig. +

+ +
+
+ + ({ + id: n.id, + bezeichnung: n.bezeichnung, + erlaeuterung: n.erlaeuterung, + status: n.status, + angefordertAm: n.angefordertAm ? alsIsoTag(n.angefordertAm) : null, + fristBis: n.fristBis ? alsIsoTag(n.fristBis) : null, + uebersandtAm: n.uebersandtAm ? alsIsoTag(n.uebersandtAm) : null, + begruendungNichtVorhanden: n.begruendungNichtVorhanden, + reihenfolge: n.reihenfolge, + ausCheckliste: n.ausCheckliste, + anzahlDokumente: n._count.verknuepfungen, + }))} + /> + + {darfSchreiben && ( + n.ausCheckliste).length} + /> + )} + + + +
+
+ Bescheide + + Maßgeblich für den Fristbeginn ist das Zugangsdatum, nicht das Datum auf dem + Bescheid. + +
+ {darfSchreiben && ( + + )} +
+
+ + + {antrag.bescheide.length === 0 ? ( + + ) : ( +
    + {antrag.bescheide.map((bescheid) => { + const frist = rechtsbehelfsfristBerechnen({ + zugegangenAm: bescheid.zugegangenAm, + bescheidVom: bescheid.bescheidVom, + fristMonate: bescheid.rechtsbehelfsfristMonate, + heute, + }) + const ampel = fristAmpel(frist) + + return ( +
  • +
    +
    + + {bescheid.art} vom {formatDatum(bescheid.bescheidVom)} + +

    + Zugang {formatDatum(bescheid.zugegangenAm)} + {bescheid.betragCent !== null && ` · ${formatCent(bescheid.betragCent)}`} + {bescheid.zeitraumVon && + ` · Zeitraum ${formatDatum(bescheid.zeitraumVon)} – ${formatDatum(bescheid.zeitraumBis)}`} +

    +

    + {naechsterSchritt(bescheid.pruefStatus, frist.tageVerbleibend)} +

    +
    +
    + + {bescheid.pruefStatus === 'UNGEPRUEFT' && ( + {ampel.text} + )} +
    +
    +
  • + ) + })} +
+ )} + +
+
+ + {darfSchreiben && ( +
+ + +
+ )} +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/heimkosten/page.tsx b/src/app/(app)/sozialhilfe/heimkosten/page.tsx new file mode 100644 index 0000000..256b71f --- /dev/null +++ b/src/app/(app)/sozialhilfe/heimkosten/page.tsx @@ -0,0 +1,318 @@ +import Link from 'next/link' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { HeimkostenFormular } from '@/components/sozialhilfe/heimkosten-formular' +import { HeimkostenTabelle, type MonatsZeile } from '@/components/sozialhilfe/heimkosten-tabelle' +import { centZuEingabe } from '@/lib/sozialhilfe/betrag' +import { db, nichtGeloescht } from '@/lib/db' +import { heuteAlsTag } from '@/lib/fristen/datum' +import { + BARBETRAG_CENT, + BEKLEIDUNGSPAUSCHALE_HINWEIS, + KONSTANTEN_NAECHSTE_PRUEFUNG, + KONSTANTEN_STAND, +} from '@/lib/sozialhilfe/konstanten' +import { + heimkostenAuswerten, + jahresverlaufBilden, + leistungszuschlagProzent, + leistungszuschlagStufe, + monatsname, + pflegekassePauschaleCent, + pflegekasseRechtsgrundlage, + verweildauerMonate, +} from '@/lib/sozialhilfe/heimkosten' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' +import { formatCent } from '@/lib/utils' +import { Info, TrendingDown } from 'lucide-react' + +export const dynamic = 'force-dynamic' + +/** + * Heimkosten-Gegenüberstellung. + * + * Die eine Zahl, um die es geht, ist die Deckungslücke: Was bleibt übrig, + * nachdem Pflegekasse, Rente und Sozialhilfe gezahlt haben? Sie steht je + * Monat und als Jahressumme da - denn eine Lücke von 180 Euro im Monat + * sind über ein Jahr mehr als zweitausend. + */ +export default async function HeimkostenSeite({ + searchParams, +}: { + searchParams: Promise<{ jahr?: string }> +}) { + const { betreuteId, betreute } = await sozialhilfeKontext() + const parameter = await searchParams + + const heute = heuteAlsTag() + const gewaehltesJahr = Number.parseInt(parameter.jahr ?? '', 10) + const jahr = Number.isFinite(gewaehltesJahr) ? gewaehltesJahr : heute.getUTCFullYear() + + const [monate, heimvertrag] = await Promise.all([ + db.heimkostenmonat.findMany({ + where: { betreuteId, jahr }, + orderBy: { monat: 'asc' }, + }), + db.heimvertrag.findUnique({ where: { betreuteId } }), + ]) + + const ergebnisse = monate.map((m) => + heimkostenAuswerten({ + jahr: m.jahr, + monat: m.monat, + pflegeEigenanteilCent: m.pflegeEigenanteilCent, + unterkunftVerpflegungCent: m.unterkunftVerpflegungCent, + investitionskostenCent: m.investitionskostenCent, + ausbildungsumlageCent: m.ausbildungsumlageCent, + zusatzleistungenCent: m.zusatzleistungenCent, + pflegekasseCent: m.pflegekasseCent, + leistungszuschlagCent: m.leistungszuschlagCent, + renteCent: m.renteCent, + sonstigeEinkuenfteCent: m.sonstigeEinkuenfteCent, + sozialhilfeCent: m.sozialhilfeCent, + eigenmittelCent: m.eigenmittelCent, + barbetragCent: m.barbetragCent, + bekleidungspauschaleCent: m.bekleidungspauschaleCent, + abwesenheitstage: m.abwesenheitstage, + }), + ) + + const verlauf = jahresverlaufBilden(jahr, ergebnisse) + + const zeilen: MonatsZeile[] = Array.from({ length: 12 }, (_, i) => { + const monat = i + 1 + const ergebnis = ergebnisse.find((e) => e.monat === monat) + const roh = monate.find((m) => m.monat === monat) + return { + jahr, + monat, + heimkostenCent: ergebnis?.heimkostenCent ?? 0n, + gesamtbedarfCent: ergebnis?.gesamtbedarfCent ?? 0n, + deckungCent: ergebnis?.deckungCent ?? 0n, + deckungslueckeCent: ergebnis?.deckungslueckeCent ?? 0n, + ueberdeckungCent: ergebnis?.ueberdeckungCent ?? 0n, + abwesenheitstage: roh?.abwesenheitstage ?? 0, + erfasst: ergebnis !== undefined, + } + }) + + // Vorschläge fürs Formular: Pauschale der Pflegekasse zum Pflegegrad + // und der gesetzliche Mindest-Barbetrag. + const pauschale = pflegekassePauschaleCent(betreute.pflegegrad) + const vorschlagPflegekasse = pauschale + ? { + betrag: centZuEingabe(pauschale), + text: `Pflegegrad ${betreute.pflegegrad}: ${formatCent(pauschale)} monatlich (${pflegekasseRechtsgrundlage(betreute.pflegegrad)}, Stand ${KONSTANTEN_STAND}).`, + } + : null + + const einzug = heimvertrag?.einzugAm ?? betreute.heimEinzugAm ?? null + const verweildauer = einzug ? verweildauerMonate(einzug, heute) : null + + const naechsterOffenerMonat = + zeilen.find((z) => !z.erfasst && z.monat <= (jahr === heute.getUTCFullYear() ? heute.getUTCMonth() + 1 : 12)) + ?.monat ?? (jahr === heute.getUTCFullYear() ? heute.getUTCMonth() + 1 : 1) + + const jahre = Array.from({ length: 6 }, (_, i) => heute.getUTCFullYear() - 3 + i) + + return ( +
+ + {jahre.map((j) => ( + + {j} + + ))} + + } + /> + +
+ + +

Bedarf {jahr}

+

+ {formatCent(verlauf.summeBedarfCent)} +

+
+
+ + +

Deckung {jahr}

+

+ {formatCent(verlauf.summeDeckungCent)} +

+
+
+ 0n ? 'border-ampel-rot/40' : undefined}> + +

Deckungslücke {jahr}

+

0n ? 'text-ampel-rot' : 'text-ampel-gruen' + }`} + > + {formatCent(verlauf.summeDeckungslueckeCent)} +

+
+
+
+ + {verlauf.groessteLuecke && ( + + + )} + + + + Jahresverlauf + + Gesamtbedarf ist die Heimrechnung plus Barbetrag und Bekleidungspauschale. + + + + + + + + {ergebnisse.some((e) => e.hinweise.length > 0) && ( + + + Was auffällt + + +
    + {ergebnisse.flatMap((e) => + e.hinweise.map((hinweis, i) => ( +
  • + {monatsname(e.monat)}: + {hinweis} +
  • + )), + )} +
+
+
+ )} + + {ergebnisse.some((e) => e.abwesenheit.abwesenheitstage > 0) && ( + + + Abwesenheitstage + + Was die Abwesenheit voraussichtlich bringt — eine Schätzung nach dem Rahmenvertrag, + nicht die Abrechnung. + + + +
    + {ergebnisse + .filter((e) => e.abwesenheit.abwesenheitstage > 0) + .map((e) => ( +
  • + {monatsname(e.monat)}: + {e.abwesenheit.erklaerung} Geschätzte Minderung{' '} + + {formatCent(e.abwesenheit.geschaetzteMinderungCent)} + + , die Lücke sänke damit auf{' '} + + {formatCent(e.deckungslueckeNachAbwesenheitCent)} + + . +
  • + ))} +
+ +
+
+ )} + + + + +
+

Monat erfassen oder ändern

+ +
+ + +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/leistungen/page.tsx b/src/app/(app)/sozialhilfe/leistungen/page.tsx new file mode 100644 index 0000000..0acfa37 --- /dev/null +++ b/src/app/(app)/sozialhilfe/leistungen/page.tsx @@ -0,0 +1,76 @@ +import { Sparkles } from 'lucide-react' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { LeistungenListe, type LeistungZeile } from '@/components/sozialhilfe/leistungen-liste' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { alsIsoTag } from '@/lib/fristen/datum' +import { katalogleistung, LEISTUNGSKATALOG, nochNichtAngelegt } from '@/lib/sozialhilfe/leistungskatalog' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' + +export const dynamic = 'force-dynamic' + +/** + * Katalog weiterer Leistungen. + * + * Neben der Hilfe zur Pflege liegt eine Handvoll Ansprüche, die niemand + * von sich aus anbietet. Sie gehen verloren, weil man sie nicht kennt oder + * weil die jährliche Wiederholung untergeht. Das bayerische + * Landespflegegeld verfällt ersatzlos, wenn der Antrag nicht bis zum 31.12. + * gestellt ist. + */ +export default async function LeistungenSeite() { + const { betreuteId, darfSchreiben } = await sozialhilfeKontext() + + const leistungen = await db.leistung.findMany({ + where: { betreuteId, ...nichtGeloescht }, + orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }], + }) + + const zeilen: LeistungZeile[] = leistungen.map((l) => ({ + id: l.id, + katalogSchluessel: l.katalogSchluessel, + bezeichnung: l.bezeichnung, + traeger: l.traeger ?? '', + rechtsgrundlage: l.rechtsgrundlage ?? '', + status: l.status, + beantragtAm: l.beantragtAm ? alsIsoTag(l.beantragtAm) : null, + bewilligtAm: l.bewilligtAm ? alsIsoTag(l.bewilligtAm) : null, + gueltigBis: l.gueltigBis ? alsIsoTag(l.gueltigBis) : null, + betragCent: centZuZahl(l.betragCent), + jaehrlichNeuBeantragen: l.jaehrlichNeuBeantragen, + notizen: l.notizen ?? '', + katalog: l.katalogSchluessel ? (katalogleistung(l.katalogSchluessel) ?? null) : null, + })) + + const offeneKatalogeintraege = nochNichtAngelegt(leistungen.map((l) => l.katalogSchluessel)) + + return ( +
+ + + {zeilen.length === 0 && offeneKatalogeintraege.length === LEISTUNGSKATALOG.length && !darfSchreiben ? ( + + ) : ( + + )} + + +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/neu/page.tsx b/src/app/(app)/sozialhilfe/neu/page.tsx new file mode 100644 index 0000000..33632d4 --- /dev/null +++ b/src/app/(app)/sozialhilfe/neu/page.tsx @@ -0,0 +1,49 @@ +import { redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { AntragFormular } from '@/components/sozialhilfe/antrag-formular' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' + +export const dynamic = 'force-dynamic' + +/** + * Neue Antragsakte. + * + * Mehrere Anträge nebeneinander sind der Normalfall: Erstantrag, + * Weiterbewilligung, dazu oft ein eigener Antrag auf Grundsicherung. Jeder + * bekommt seine eigene Akte mit eigenen Nachforderungen und Bescheiden. + */ +export default async function NeueAntragsakteSeite() { + const { darfSchreiben } = await sozialhilfeKontext('schreiben') + if (!darfSchreiben) redirect('/sozialhilfe') + + return ( +
+ + + +
+ ) +} diff --git a/src/app/(app)/sozialhilfe/page.tsx b/src/app/(app)/sozialhilfe/page.tsx new file mode 100644 index 0000000..ca851fa --- /dev/null +++ b/src/app/(app)/sozialhilfe/page.tsx @@ -0,0 +1,276 @@ +import Link from 'next/link' +import { Coins, FileStack, Landmark, Plus, Receipt, Sparkles } from 'lucide-react' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Fortschritt } from '@/components/ui/fortschritt' +import { BescheidAbzeichen } from '@/components/sozialhilfe/abzeichen' +import { db, nichtGeloescht } from '@/lib/db' +import { heuteAlsTag } from '@/lib/fristen/datum' +import { trackerFortschritt } from '@/lib/sozialhilfe/nachforderungen' +import { fristAmpel, rechtsbehelfsfristBerechnen } from '@/lib/sozialhilfe/rechtsbehelf' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' +import { formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +/** + * Einstieg ins Sozialhilfeverfahren. + * + * Oben steht, was drängt: unvollständige Nachforderungen und Bescheide, + * deren Widerspruchsfrist läuft. Erst darunter die Aktenübersicht. Die + * Seite soll die Frage "was muss ich heute tun?" beantworten, nicht "was + * habe ich alles erfasst". + */ +export default async function SozialhilfeSeite() { + const { betreuteId, betreute, darfSchreiben } = await sozialhilfeKontext() + + const antraege = await db.sozialhilfeAntrag.findMany({ + where: { betreuteId, ...nichtGeloescht }, + include: { + nachforderungen: { where: nichtGeloescht, orderBy: { reihenfolge: 'asc' } }, + bescheide: { where: nichtGeloescht, orderBy: { bescheidVom: 'desc' } }, + }, + orderBy: [{ antragAm: 'desc' }, { erstelltAm: 'desc' }], + }) + + const heute = heuteAlsTag() + + // Bescheide, bei denen die Widerspruchsfrist noch läuft und niemand + // hingesehen hat. Das ist die teuerste Nachlässigkeit im Verfahren. + const offeneBescheide = antraege + .flatMap((antrag) => + antrag.bescheide + .filter((b) => b.pruefStatus === 'UNGEPRUEFT') + .map((bescheid) => ({ + antrag, + bescheid, + frist: rechtsbehelfsfristBerechnen({ + zugegangenAm: bescheid.zugegangenAm, + bescheidVom: bescheid.bescheidVom, + fristMonate: bescheid.rechtsbehelfsfristMonate, + heute, + }), + })), + ) + .sort((a, b) => (a.frist.tageVerbleibend ?? 9999) - (b.frist.tageVerbleibend ?? 9999)) + + return ( +
+ + Das Verfahren beim Sozialhilfeträger für {betreute.vorname} {betreute.nachname}: + Antragsakte, angeforderte Unterlagen, Bescheide und die Heimkosten Monat für Monat. + + } + pfad={[{ titel: 'Start', href: '/heute' }, { titel: 'Sozialhilfe' }]} + aktionen={ + darfSchreiben && ( + + ) + } + /> + + {offeneBescheide.length > 0 && ( + + + Ungeprüfte Bescheide + + Solange hier etwas steht, läuft eine Widerspruchsfrist ungenutzt weiter. + + + +
    + {offeneBescheide.map(({ antrag, bescheid, frist }) => { + const ampel = fristAmpel(frist) + return ( +
  • + + {bescheid.art} vom {formatDatum(bescheid.bescheidVom)} + + {antrag.traeger} + {antrag.aktenzeichen ? ` · Az. ${antrag.aktenzeichen}` : ''} + {frist.widerspruchMoeglichBis + ? ` · Widerspruch möglich bis ${formatDatum(frist.widerspruchMoeglichBis)}` + : ' · Zugangsdatum fehlt'} + + + {ampel.text} +
  • + ) + })} +
+ +
+
+ )} + +
+ + + +
+ +
+

Antragsakten

+ + {antraege.length === 0 ? ( + + Antragsakte anlegen + + ) + } + /> + ) : ( +
    + {antraege.map((antrag) => { + const fortschritt = trackerFortschritt( + antrag.nachforderungen.map((n) => ({ + id: n.id, + bezeichnung: n.bezeichnung, + status: n.status, + fristBis: n.fristBis, + })), + heute, + ) + const letzterBescheid = antrag.bescheide[0] + + return ( +
  • + + +
    +
    + + + {antrag.leistungsart} + + + + {antrag.traeger} + {antrag.aktenzeichen ? ` · Az. ${antrag.aktenzeichen}` : ''} + {antrag.sachbearbeiter ? ` · ${antrag.sachbearbeiter}` : ''} + +
    + {letzterBescheid && } +
    +
    + + +
    +
    +
    Antrag gestellt
    +
    {formatDatum(antrag.antragAm)}
    +
    +
    +
    Eingang beim Träger
    +
    {formatDatum(antrag.eingangBeimTraegerAm)}
    +
    +
    +
    Bewilligt
    +
    + {antrag.bewilligtVon || antrag.bewilligtBis + ? `${formatDatum(antrag.bewilligtVon)} – ${formatDatum(antrag.bewilligtBis)}` + : 'noch nicht'} +
    +
    +
    + + {fortschritt.gesamt > 0 && ( + + )} + +
    + {fortschritt.ueberfaellig.length > 0 && ( + + {fortschritt.ueberfaellig.length} überfällig + + )} + {fortschritt.dieseWoche.length > 0 && ( + + {fortschritt.dieseWoche.length} diese Woche fällig + + )} + + +
    + + +
    +
    +
  • + ) + })} +
+ )} +
+ + +
+ ) +} + +function Bereichskachel({ + href, + titel, + text, + icon: Icon, +}: { + href: string + titel: string + text: string + icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }> +}) { + return ( + + + {titel} + {text} + + ) +} diff --git a/src/app/(app)/sozialhilfe/vermoegen/page.tsx b/src/app/(app)/sozialhilfe/vermoegen/page.tsx new file mode 100644 index 0000000..1ae9ecd --- /dev/null +++ b/src/app/(app)/sozialhilfe/vermoegen/page.tsx @@ -0,0 +1,229 @@ +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Card, CardContent } from '@/components/ui/card' +import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste' +import { oeffnen } from '@/lib/crypto/feld' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { alsIsoTag } from '@/lib/fristen/datum' +import { VERMOEGENSART_BESCHRIFTUNG, VERMOEGENSRELEVANTE_VERTRAGSARTEN, VERTRAGSART_BESCHRIFTUNG } from '@/lib/sozialhilfe/arten' +import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen' +import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht' +import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' +import { formatCent } from '@/lib/utils' +import { AlertCircle } from 'lucide-react' + +export const dynamic = 'force-dynamic' + +/** + * Vermögens-Bewertungsansicht. + * + * Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was + * geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht. + * Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung". + */ +export default async function VermoegensbewertungSeite() { + const { betreuteId, darfSchreiben } = await sozialhilfeKontext() + + const [posten, vertraege] = await Promise.all([ + db.vermoegensposten.findMany({ + where: { betreuteId, ...nichtGeloescht }, + include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, + orderBy: { bezeichnung: 'asc' }, + }), + db.vertrag.findMany({ + where: { + betreuteId, + art: { in: [...VERMOEGENSRELEVANTE_VERTRAGSARTEN] }, + ...nichtGeloescht, + }, + orderBy: { bezeichnung: 'asc' }, + }), + ]) + + // Verträge, die schon als Vermögensposten geführt werden, nicht doppelt + // zeigen - sonst wäre die Summe zweimal drin. + const bereitsAlsPosten = new Set( + posten.map((p) => p.vertragId).filter((id): id is string => id !== null), + ) + + const zeilen: ListenPosten[] = [ + ...posten.map((p) => { + const geoeffnet = oeffnen('Vermoegensposten', p) + const stand = p.wertstaende[0] + return { + id: p.id, + bezeichnung: p.bezeichnung, + herkunft: 'VERMOEGEN' as const, + art: p.art, + artBeschriftung: VERMOEGENSART_BESCHRIFTUNG[p.art], + bewertung: p.bewertungSozialhilfe, + wertCent: stand ? centZuZahl(stand.wertCent) : null, + wertStichtag: stand ? alsIsoTag(stand.stichtag) : null, + begruendung: geoeffnet.bewertungBegruendung ?? '', + zusatz: stand ? stand.wertquelle : null, + } + }), + ...vertraege + .filter((v) => !bereitsAlsPosten.has(v.id)) + .map((v) => { + const geoeffnet = oeffnen('Vertrag', v) + return { + id: v.id, + bezeichnung: v.bezeichnung, + herkunft: 'VERTRAG' as const, + art: v.art, + artBeschriftung: VERTRAGSART_BESCHRIFTUNG[v.art], + bewertung: v.bewertungSozialhilfe, + // Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird + // als Vermögensposten mit Wertstand geführt. Bis dahin: offen. + wertCent: null, + wertStichtag: null, + begruendung: geoeffnet.bewertungBegruendung ?? '', + zusatz: v.gesellschaft, + } + }), + ] + + const uebersicht = uebersichtBilden( + zeilen.map((z) => ({ + id: z.id, + bezeichnung: z.bezeichnung, + herkunft: z.herkunft, + artBeschriftung: z.artBeschriftung, + bewertung: z.bewertung, + wertCent: z.wertCent === null ? null : BigInt(z.wertCent), + wertStichtag: null, + begruendung: z.begruendung, + href: '#', + })), + ) + + const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent) + + return ( +
+ + +
+ + + + +
+ + + +
+ + Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII) + + + {formatCent(vergleich.freibetragCent)} + +
+
+ Über dem Freibetrag + + {formatCent(vergleich.ueberFreibetragCent)} + +
+

{vergleich.hinweis}

+ +
+
+ + {uebersicht.ungeprueft.length > 0 && ( + + + )} + + {uebersicht.ohneBegruendung.length > 0 && ( + + + )} + + + + +
+ ) +} + +function Summenkachel({ + titel, + betrag, + anzahl, + ohneWert, + farbe, +}: { + titel: string + betrag: bigint + anzahl: number + ohneWert: number + farbe: string +}) { + return ( + + +

{titel}

+

{formatCent(betrag)}

+

+ {anzahl} {anzahl === 1 ? 'Posten' : 'Posten'} + {ohneWert > 0 && `, davon ${ohneWert} ohne Wert`} +

+
+
+ ) +} diff --git a/src/app/api/dokumente/datei/[signatur]/route.ts b/src/app/api/dokumente/datei/[signatur]/route.ts new file mode 100644 index 0000000..a4b5db4 --- /dev/null +++ b/src/app/api/dokumente/datei/[signatur]/route.ts @@ -0,0 +1,170 @@ +import { NextResponse } from 'next/server' +import type { Modul } from '@prisma/client' +import { protokollieren } from '@/lib/audit' +import { darf } from '@/lib/auth/rollen' +import { aktuellerBenutzer } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { typErkennen } from '@/lib/speicher/dateityp' +import { betreuteIdAusSchluessel, lesen } from '@/lib/speicher' +import { zugriffTokenPruefen } from '@/lib/speicher/schluessel' + +/** + * Auslieferung einer abgelegten Datei beim Dateitreiber. + * + * Hierher zeigt `signierteUrl()`, wenn kein Objektspeicher konfiguriert + * ist. Vier Schranken stehen hintereinander, und jede muss halten: + * + * 1. **Signatur** - der Link traegt einen HMAC ueber Schluessel und + * Ablauf. Ohne AUTH_SECRET laesst er sich weder faelschen noch + * verlaengern. + * 2. **Anmeldung** - ein weitergeleiteter Link nuetzt einem Fremden + * nichts, er ist nicht angemeldet. + * 3. **Berechtigung** - geprueft wird gegen die betreuteId, die im + * Schluessel steht. Wer diese Person nicht betreut, kommt nicht an + * ihre Unterlagen, auch nicht mit gueltigem Link. + * 4. **Auslieferung** - immer als Anhang, nie als HTML, mit + * X-Content-Type-Options: nosniff. Eine hochgeladene Datei darf + * niemals als Seite im Kontext dieser Anwendung ausgefuehrt werden. + * + * Jeder erfolgreiche Abruf steht danach im Audit-Log. + */ + +const MODUL: Modul = 'DOKUMENTE' + +/** Nur diese Typen werden ueberhaupt ausgeliefert - HTML ist nicht dabei. */ +const AUSLIEFERBAR = new Set([ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/tiff', + 'image/heic', +]) + +function nurText(status: number, meldung: string): NextResponse { + return new NextResponse(meldung, { + status, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'no-store', + }, + }) +} + +export async function GET( + _anfrage: Request, + { params }: { params: Promise<{ signatur: string }> }, +): Promise { + const { signatur } = await params + + // 1. Signatur und Ablauf + const geprueft = zugriffTokenPruefen(decodeURIComponent(signatur)) + if (!geprueft.gueltig) { + return nurText( + geprueft.grund === 'abgelaufen' ? 410 : 400, + geprueft.grund === 'abgelaufen' + ? 'Dieser Link ist abgelaufen. Öffne das Dokument erneut in der Akte.' + : 'Dieser Link ist ungültig.', + ) + } + const schluessel = geprueft.schluessel + + // 2. Angemeldet? + const benutzer = await aktuellerBenutzer() + if (!benutzer) return nurText(401, 'Bitte melde dich an.') + + // 3. Zugriff auf genau diese betreute Person + const betreuteId = betreuteIdAusSchluessel(schluessel) + if (!betreuteId) return nurText(400, 'Dieser Link ist ungültig.') + + const betreute = await db.betreute.findFirst({ + where: { id: betreuteId, ...nichtGeloescht }, + select: { id: true }, + }) + if (!betreute) return nurText(404, 'Nicht gefunden.') + + const eintrag = await db.betreuungsZugang.findUnique({ + where: { userId_betreuteId: { userId: benutzer.id, betreuteId } }, + select: { rolle: true, freigegebeneModule: true }, + }) + const zugang = eintrag + ? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule } + : benutzer.rolle === 'ADMIN' + ? { rolle: 'ADMIN' as const, freigegebeneModule: [] } + : null + + if (!darf(zugang, MODUL, 'lesen')) { + return nurText(403, 'Für diesen Bereich fehlt dir die Berechtigung.') + } + + // Der Datensatz zur Datei - fuer Anzeigename und Protokolleintrag. + const dokument = await db.dokument.findFirst({ + where: { objektSchluessel: schluessel, betreuteId }, + select: { id: true, titel: true, dateiname: true, geloeschtAm: true }, + }) + + let inhalt: Buffer + try { + inhalt = await lesen(schluessel) + } catch { + return nurText(404, 'Die Datei liegt nicht mehr im Speicher.') + } + + // 4. Auslieferung: Typ am Inhalt bestimmen, nie am gespeicherten Feld. + const erkannt = typErkennen(inhalt) + const typ = erkannt && AUSLIEFERBAR.has(erkannt.mimeTyp) ? erkannt.mimeTyp : 'application/octet-stream' + + const name = dateinamen(dokument?.dateiname ?? null, schluessel) + + await protokollieren({ + aktion: 'DOKUMENT_HERUNTERGELADEN', + entitaet: 'Dokument', + entitaetId: dokument?.id ?? null, + bezeichnung: dokument?.titel ?? name, + betreuteId, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + }) + + return new NextResponse(new Uint8Array(inhalt), { + status: 200, + headers: { + 'Content-Type': typ, + 'Content-Length': String(inhalt.length), + // Niemals im Browser rendern - auch dann nicht, wenn jemand einen + // Weg gefunden haette, eine HTML-Datei hier abzulegen. + 'Content-Disposition': `attachment; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`, + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + 'Referrer-Policy': 'no-referrer', + // Private Daten gehoeren in keinen Zwischenspeicher. + 'Cache-Control': 'private, no-store, max-age=0', + ...(dokument?.geloeschtAm ? { 'X-Papierkorb': '1' } : {}), + }, + }) +} + +function dateinamen(gespeichert: string | null, schluessel: string): string { + if (gespeichert && gespeichert.trim() !== '') return gespeichert + const endung = schluessel.slice(schluessel.lastIndexOf('.') + 1) + return `dokument.${endung}` +} + +/** Fallback fuer Browser, die filename* nicht verstehen. */ +function asciiName(name: string): string { + return ( + name + .replace(/[\u00e4]/g, 'ae') + .replace(/[\u00f6]/g, 'oe') + .replace(/[\u00fc]/g, 'ue') + .replace(/[\u00c4]/g, 'Ae') + .replace(/[\u00d6]/g, 'Oe') + .replace(/[\u00dc]/g, 'Ue') + .replace(/[\u00df]/g, 'ss') + // eslint-disable-next-line no-control-regex + .replace(/[^\u0020-\u007e]/g, '_') + .replace(/["\\]/g, '_') || 'dokument' + ) +} diff --git a/src/app/datenschutz/page.tsx b/src/app/datenschutz/page.tsx new file mode 100644 index 0000000..e966e29 --- /dev/null +++ b/src/app/datenschutz/page.tsx @@ -0,0 +1,114 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +export const metadata: Metadata = { title: 'Datenschutz' } + +/** + * Datenschutzhinweise. + * + * Der Text kommt aus docs/datenschutzhinweise.md - dieselbe Datei, die man + * auch ausdrucken und der betreuten Person aushaendigen kann. Zwei Quellen + * fuer denselben Text waeren zwei Quellen fuer Fehler. + * + * Die Umsetzung ist bewusst schlicht: keine Markdown-Bibliothek, nur die + * wenigen Auszeichnungen, die im Dokument wirklich vorkommen. + */ +function alsAbsaetze(markdown: string) { + const zeilen = markdown.split(/\r?\n/) + const bloecke: React.ReactNode[] = [] + let absatz: string[] = [] + + const absatzSchliessen = (schluessel: number) => { + if (absatz.length === 0) return + bloecke.push( +

+ {absatz.join(' ')} +

, + ) + absatz = [] + } + + zeilen.forEach((zeile, i) => { + const roh = zeile.trim() + + // Der Hinweisblock fuer den Ausfuellenden gehoert nicht auf die Seite. + if (roh.startsWith('>')) return + + if (roh === '' || roh === '---') { + absatzSchliessen(i) + return + } + if (roh.startsWith('## ')) { + absatzSchliessen(i) + bloecke.push( +

+ {roh.slice(3)} +

, + ) + return + } + if (roh.startsWith('# ')) { + absatzSchliessen(i) + bloecke.push( +

+ {roh.slice(2)} +

, + ) + return + } + if (roh.startsWith('- ')) { + absatzSchliessen(i) + bloecke.push( +
  • + {roh.slice(2)} +
  • , + ) + return + } + // Tabellenzeilen werden als einfache Aufzaehlung dargestellt. + if (roh.startsWith('|')) { + absatzSchliessen(i) + const spalten = roh.split('|').map((s) => s.trim()).filter(Boolean) + if (spalten.every((s) => /^-+$/.test(s))) return + bloecke.push( +
    + {spalten[0]} + {spalten.slice(1).join(' · ')} +
    , + ) + return + } + + absatz.push(roh) + }) + + absatzSchliessen(zeilen.length) + return bloecke +} + +export default async function DatenschutzSeite() { + const pfad = path.join(process.cwd(), 'docs', 'datenschutzhinweise.md') + let inhalt: string + + try { + inhalt = await readFile(pfad, 'utf8') + } catch { + inhalt = + '# Datenschutzhinweise\n\nDer Text konnte nicht geladen werden. ' + + 'Er liegt in docs/datenschutzhinweise.md.' + } + + return ( +
    +
    {alsAbsaetze(inhalt)}
    + +

    + + Zurück zur Anmeldung + +

    +
    + ) +} diff --git a/src/components/berichte/belegmappe-formular.tsx b/src/components/berichte/belegmappe-formular.tsx new file mode 100644 index 0000000..bd9bf6c --- /dev/null +++ b/src/components/berichte/belegmappe-formular.tsx @@ -0,0 +1,111 @@ +'use client' + +import * as React from 'react' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { FolderArchive } from 'lucide-react' +import { Feld } from '@/components/feld' +import { Input } from '@/components/ui/input' +import { Select } from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { Meldung } from '@/components/berichte/meldung' +import { belegmappeAnlegen, type Aktionszustand } from '@/server/actions/berichte' + +function Absenden() { + const { pending } = useFormStatus() + return ( + + ) +} + +/** + * Auswahlmaske fuer die Belegmappe. + * + * Zwei Wege fuehren zu einer Mappe: ein Zeitraum (fuer die Rechnungslegung + * gegenueber dem Gericht) oder ein einzelner Vorgang (wenn der Bezirk + * fragt, was es mit einer bestimmten Sache auf sich hat). Das eigentliche + * Zusammenlegen der Belege macht das Dokumentenmodul - dort sitzt das + * Wissen darueber, welcher Beleg zu welcher Buchung gehoert. + */ +export function BelegmappeFormular({ + betreuteId, + vorgaenge, + vorschlagVon, + vorschlagBis, +}: { + betreuteId: string + vorgaenge: { id: string; titel: string }[] + vorschlagVon: string + vorschlagBis: string +}) { + const [zustand, aktion] = useActionState(belegmappeAnlegen, {}) + const [quelle, setQuelle] = React.useState<'zeitraum' | 'vorgang'>('zeitraum') + + return ( +
    + + + + + + + + + {quelle === 'zeitraum' ? ( +
    + + + + + + +
    + ) : ( + + + + )} + + + + + + + + ) +} diff --git a/src/components/berichte/entwurf-editor.tsx b/src/components/berichte/entwurf-editor.tsx new file mode 100644 index 0000000..5b6987a --- /dev/null +++ b/src/components/berichte/entwurf-editor.tsx @@ -0,0 +1,194 @@ +'use client' + +import * as React from 'react' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { FileDown, FileType2, RefreshCcw, Save } from 'lucide-react' +import { Feld } from '@/components/feld' +import { Textarea } from '@/components/ui/textarea' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Meldung } from '@/components/berichte/meldung' +import { + entwurfNeuBerechnen, + entwurfSpeichern, + jahresberichtAusgeben, + type Aktionszustand, +} from '@/server/actions/berichte' + +/** Wie oft der Entwurf im Hintergrund gesichert wird, wenn sich etwas ändert. */ +const AUTOSPEICHERN_MS = 60_000 + +function Knopf({ + children, + variante = 'default', + laeuftText, +}: { + children: React.ReactNode + variante?: 'default' | 'outline' | 'secondary' + laeuftText: string +}) { + const { pending } = useFormStatus() + return ( + + ) +} + +/** + * Der Editor fuer den Jahresberichtsentwurf. + * + * Der wichtigste Satz dieser Seite steht oben im Kasten: Das ist ein + * Entwurf. Die Anwendung hat zusammengetragen, was sie weiss - aber sie + * war nicht dabei. Was das Gericht liest, muss der Betreuer verantworten, + * deshalb wird hier geschrieben und nicht nur bestaetigt. + * + * Zwischenspeichern geht jederzeit von Hand und laeuft zusaetzlich alle + * sechzig Sekunden von allein - ein verlorener Nachmittag Schreibarbeit + * waere die teuerste Art, Speicherplatz zu sparen. + */ +export function EntwurfEditor({ + berichtId, + entwurf, + offeneStellen, + ergaenzenMarke, + hatPdf, +}: { + berichtId: string + entwurf: string + offeneStellen: number + ergaenzenMarke: string + hatPdf: boolean +}) { + const [text, setText] = React.useState(entwurf) + const [gesichert, setGesichert] = React.useState(entwurf) + const geaendert = text !== gesichert + + const [speicherstand, speichern] = useActionState(entwurfSpeichern, {}) + const [ausgabestand, ausgeben] = useActionState(jahresberichtAusgeben, {}) + const [neustand, neuBerechnen] = useActionState(entwurfNeuBerechnen, {}) + + const formular = React.useRef(null) + + // Nach erfolgreichem Speichern gilt der aktuelle Text als gesichert. + React.useEffect(() => { + if (speicherstand.erfolg) setGesichert((alt) => (alt === text ? alt : text)) + // Absichtlich nur am Erfolg haengend: Waehrend des Tippens soll nichts passieren. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [speicherstand.erfolg]) + + // Regelmaessiges Sichern im Hintergrund. + React.useEffect(() => { + if (!geaendert) return + const uhr = setTimeout(() => formular.current?.requestSubmit(), AUTOSPEICHERN_MS) + return () => clearTimeout(uhr) + }, [geaendert, text]) + + // Warnung beim Verlassen mit ungesicherten Aenderungen. + React.useEffect(() => { + if (!geaendert) return + const beimVerlassen = (ereignis: BeforeUnloadEvent) => ereignis.preventDefault() + window.addEventListener('beforeunload', beimVerlassen) + return () => window.removeEventListener('beforeunload', beimVerlassen) + }, [geaendert]) + + const restlicheStellen = text.split(ergaenzenMarke).length - 1 + + return ( +
    + + Das ist ein Entwurf, kein fertiger Bericht + +

    + Zusammengestellt wurde er aus deinen Kontaktprotokollen, den Gesundheitsereignissen und + den Vorgängen. Lies ihn durch, streiche, was nicht stimmt, und schreib in deinen eigenen + Worten, was nur du weißt. Abgegeben wird nichts von allein – du erzeugst das PDF, wenn + du zufrieden bist. +

    + {restlicheStellen > 0 && ( +

    + Im Text stehen noch {restlicheStellen}{' '} + {restlicheStellen === 1 ? 'Stelle' : 'Stellen'} mit „{ergaenzenMarke}“, die auf dich + warten. +

    + )} +
    +
    + + + + + +
    + + + + +