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. +

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