docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte
Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert. README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind US-Unternehmen und unterliegen auch bei EU-Region potenziell dem CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person ist das eine Abwaegung, die man bewusst treffen muss. Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon steht. Die Datenbank hat keine Portfreigabe. Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt dauerhaft stehen. Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern auch der Schluessel. Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie sollen der betreuten Person vorgelesen werden koennen. Die Seite /datenschutz liest denselben Text aus docs/, damit es nicht zwei Fassungen gibt, die auseinanderlaufen. Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der Server in UTC und verschiebt Fristen um einen Tag. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -39,3 +39,7 @@ test-results/
|
||||
Thumbs.db
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Bildschirmfotos entstehen beim Entwickeln neu
|
||||
screenshots/
|
||||
daten/dokumente/
|
||||
|
||||
+93
@@ -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"]
|
||||
@@ -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_<datum>.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 `<select>`, und
|
||||
Eingabefelder haben mindestens 16 px Schrift. Alles drei, damit die Anwendung
|
||||
am Handy zuverlässig zu bedienen ist — im Heim und auf dem Amt.
|
||||
|
||||
---
|
||||
|
||||
## Bauprotokoll
|
||||
|
||||
Jeder Commit trägt die Trailer `Etappe:` und `Phase:`. Daraus lässt sich
|
||||
nachvollziehen, wie lange welcher Bauabschnitt gedauert hat:
|
||||
|
||||
```bash
|
||||
npm run protokoll # Tabelle auf der Konsole
|
||||
npm run protokoll:md # Markdown für docs/BAUPROTOKOLL.md
|
||||
```
|
||||
|
||||
Der aktuelle Stand liegt in [`docs/BAUPROTOKOLL.md`](docs/BAUPROTOKOLL.md).
|
||||
|
||||
@@ -136,6 +136,7 @@ services:
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
- ./scripts/sicherung.sh:/sicherung.sh:ro
|
||||
- ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro
|
||||
entrypoint: ['/bin/sh', '-c']
|
||||
command:
|
||||
- |
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Datenschutzhinweise
|
||||
|
||||
nach Art. 13 und 14 DSGVO
|
||||
|
||||
> **Textbaustein.** Die eckigen Klammern ausfüllen, dann ausdrucken und der
|
||||
> betreuten Person aushändigen bzw. vorlesen — soweit sie das aufnehmen kann.
|
||||
> Wenn nicht, gehört ein Vermerk darüber in die Akte. Mitleser bekommen
|
||||
> denselben Text, bevor sie freigeschaltet werden.
|
||||
|
||||
---
|
||||
|
||||
## Wer verarbeitet Ihre Daten?
|
||||
|
||||
[Vorname Nachname]
|
||||
[Straße, PLZ Ort]
|
||||
[E-Mail] · [Telefon]
|
||||
|
||||
Ich bin durch Beschluss des [Amtsgericht] vom [Datum], Az. [Aktenzeichen],
|
||||
zu Ihrem rechtlichen Betreuer bestellt worden.
|
||||
|
||||
---
|
||||
|
||||
## Warum verarbeite ich Ihre Daten?
|
||||
|
||||
Damit ich die Aufgaben wahrnehmen kann, die mir das Betreuungsgericht
|
||||
übertragen hat: [Aufgabenkreise eintragen, z. B. Vermögenssorge,
|
||||
Gesundheitssorge, Aufenthaltsbestimmung].
|
||||
|
||||
Konkret heißt das: Ich führe Ihre Angelegenheiten mit Behörden, Kassen und
|
||||
Versicherungen, verwalte Ihr Vermögen, dokumentiere Arztbesuche und
|
||||
Krankenhausaufenthalte, und ich muss dem Betreuungsgericht einmal im Jahr
|
||||
darüber berichten.
|
||||
|
||||
Dazu bin ich gesetzlich verpflichtet — die Grundlagen sind §§ 1814 ff. BGB,
|
||||
für die Berichte § 1863 BGB, für die Rechnungslegung §§ 1835, 1859, 1865 BGB.
|
||||
|
||||
---
|
||||
|
||||
## Welche Daten sind das?
|
||||
|
||||
- Ihr Name, Geburtsdatum und Ihre Anschrift
|
||||
- Wo Sie leben, seit wann, und wer Sie dort betreut
|
||||
- **Ihre Gesundheitsdaten**: Diagnosen, Pflegegrad, Arztbesuche,
|
||||
Krankenhausaufenthalte, Medikamente, Hilfsmittel
|
||||
- **Was Sie sich wünschen** — das ist seit der Reform 2023 der Maßstab
|
||||
meines Handelns (§ 1821 BGB), deshalb halte ich es ausdrücklich fest
|
||||
- Ihr Vermögen: Konten, Immobilien, Versicherungen, Einnahmen und Ausgaben
|
||||
- Ihre Versicherten-, Renten- und Steuernummer
|
||||
- Schreiben von und an Behörden, Gerichte, Kassen und Versicherungen
|
||||
- Notizen zu Gesprächen, die ich in Ihrer Sache geführt habe
|
||||
|
||||
---
|
||||
|
||||
## Wer bekommt diese Daten zu sehen?
|
||||
|
||||
| Wer | Wofür |
|
||||
|---|---|
|
||||
| Das Betreuungsgericht | Jahresbericht, Vermögensverzeichnis, Genehmigungsanträge |
|
||||
| [Bezirk Oberfranken] | Ihr Antrag auf Hilfe zur Pflege |
|
||||
| Kranken- und Pflegekasse | Anträge, Widersprüche |
|
||||
| Ihr Pflegeheim | soweit für die Betreuung nötig |
|
||||
| [Namen der Mitleser] | nur die Bereiche, die ich ausdrücklich freigegeben habe |
|
||||
|
||||
Der Anbieter, auf dessen Servern die Anwendung läuft, kann technisch auf die
|
||||
Daten zugreifen. Mit ihm besteht ein Vertrag nach Art. 28 DSGVO, der ihm das
|
||||
verbietet.
|
||||
|
||||
**Ihre Daten verlassen die Europäische Union nicht.** Die Server stehen in
|
||||
[Ort], Deutschland.
|
||||
|
||||
---
|
||||
|
||||
## Wie sind die Daten geschützt?
|
||||
|
||||
- Der Zugang ist durch ein Passwort **und** einen zweiten Faktor gesichert.
|
||||
Ein Passwort allein genügt nicht.
|
||||
- Besonders empfindliche Angaben — Diagnosen, Ihre Wünsche, Kontonummern,
|
||||
Versicherungsnummern, alle Notizen — sind zusätzlich **verschlüsselt**
|
||||
gespeichert. Selbst wer an die Datenbank käme, könnte sie ohne den
|
||||
separaten Schlüssel nicht lesen.
|
||||
- Jede Änderung wird protokolliert: wer, wann, was. Dieses Protokoll kann
|
||||
nachträglich niemand ändern oder löschen — auch ich nicht.
|
||||
- Es werden keine Werbe- oder Analysedienste eingesetzt. Es gibt keine
|
||||
Verbindungen zu fremden Servern.
|
||||
|
||||
---
|
||||
|
||||
## Wie lange bleiben die Daten gespeichert?
|
||||
|
||||
Solange die Betreuung läuft, und danach noch zehn Jahre. Diese Frist gibt
|
||||
es, weil ich meine Rechnungslegung so lange belegen können muss — gegenüber
|
||||
dem Gericht und gegebenenfalls gegenüber Ihren Erben.
|
||||
|
||||
Danach werden die Daten gelöscht. Endet die Betreuung vorher, gebe ich die
|
||||
Unterlagen an den nachfolgenden Betreuer oder an Sie selbst heraus.
|
||||
|
||||
---
|
||||
|
||||
## Welche Rechte haben Sie?
|
||||
|
||||
Sie haben das Recht,
|
||||
|
||||
- **Auskunft** darüber zu verlangen, welche Daten ich über Sie gespeichert
|
||||
habe (Art. 15 DSGVO) — auf Wunsch drucke ich Ihnen das aus,
|
||||
- **falsche Daten berichtigen** zu lassen (Art. 16 DSGVO),
|
||||
- die **Löschung** zu verlangen (Art. 17 DSGVO) — allerdings nur, soweit ich
|
||||
nicht gesetzlich zur Aufbewahrung verpflichtet bin,
|
||||
- die Verarbeitung **einschränken** zu lassen (Art. 18 DSGVO),
|
||||
- Ihre Daten in einem gängigen Format zu erhalten (Art. 20 DSGVO) — die
|
||||
Anwendung hat dafür eine Exportfunktion,
|
||||
- der Verarbeitung zu **widersprechen** (Art. 21 DSGVO).
|
||||
|
||||
Wenden Sie sich dafür einfach an mich.
|
||||
|
||||
**Beschwerderecht:** Wenn Sie meinen, dass ich mit Ihren Daten nicht richtig
|
||||
umgehe, können Sie sich beschweren bei
|
||||
|
||||
Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)
|
||||
Promenade 18, 91522 Ansbach
|
||||
Telefon 0981 180093-0 · poststelle@lda.bayern.de
|
||||
|
||||
---
|
||||
|
||||
## Ein Hinweis in eigener Sache
|
||||
|
||||
Ein Teil dieser Daten muss ich erheben, weil das Gesetz es verlangt — ohne
|
||||
Vermögensverzeichnis und Jahresbericht darf ich die Betreuung nicht führen.
|
||||
Bei anderen Angaben, etwa Ihren persönlichen Wünschen, geht es allein darum,
|
||||
dass ich in Ihrem Sinne entscheide. Sagen Sie mir jederzeit, wenn etwas
|
||||
davon nicht festgehalten werden soll.
|
||||
|
||||
---
|
||||
|
||||
*Stand: [Datum]*
|
||||
@@ -0,0 +1,193 @@
|
||||
# Verzeichnis von Verarbeitungstätigkeiten
|
||||
|
||||
nach Art. 30 DSGVO
|
||||
|
||||
> **Textbaustein.** Die eckigen Klammern musst du ausfüllen. Der Rest
|
||||
> beschreibt, was die Anwendung tatsächlich tut, und stimmt mit dem Code
|
||||
> überein. Stand: siehe Änderungsdatum dieser Datei.
|
||||
|
||||
---
|
||||
|
||||
## 1. Verantwortlicher
|
||||
|
||||
| Feld | Angabe |
|
||||
|---|---|
|
||||
| Name | [Vorname Nachname] |
|
||||
| Anschrift | [Straße, PLZ Ort] |
|
||||
| E-Mail | [E-Mail] |
|
||||
| Telefon | [Telefon] |
|
||||
| Funktion | Ehrenamtlich bestellter rechtlicher Betreuer nach §§ 1814 ff. BGB |
|
||||
| Bestellung durch | [Amtsgericht], Az. [Aktenzeichen], vom [Datum] |
|
||||
|
||||
**Datenschutzbeauftragter:** nicht erforderlich. Die Verarbeitung erfolgt
|
||||
durch eine einzelne natürliche Person im Rahmen einer ehrenamtlichen
|
||||
Betreuung; die Voraussetzungen des Art. 37 DSGVO bzw. § 38 BDSG liegen
|
||||
nicht vor.
|
||||
|
||||
---
|
||||
|
||||
## 2. Zwecke der Verarbeitung
|
||||
|
||||
Wahrnehmung der durch das Betreuungsgericht übertragenen Aufgabenkreise,
|
||||
insbesondere:
|
||||
|
||||
- Vermögenssorge einschließlich der Rechnungslegung gegenüber dem
|
||||
Betreuungsgericht (§§ 1835, 1859, 1865 BGB)
|
||||
- Gesundheitssorge einschließlich der Dokumentation von Behandlungen,
|
||||
Krankenhausaufenthalten und des ermittelten Willens (§ 1821 BGB)
|
||||
- Erfüllung der Berichtspflichten (§ 1863 BGB)
|
||||
- Antragstellung und Verfahrensführung gegenüber Sozialleistungsträgern
|
||||
(SGB XI, SGB XII, SGB V, SGB IX)
|
||||
- Fristenüberwachung und Nachweisführung
|
||||
|
||||
---
|
||||
|
||||
## 3. Rechtsgrundlagen
|
||||
|
||||
| Datenkategorie | Rechtsgrundlage |
|
||||
|---|---|
|
||||
| Allgemeine Personen-, Vermögens- und Vertragsdaten | Art. 6 Abs. 1 lit. c DSGVO — rechtliche Verpflichtung aus §§ 1814 ff. BGB |
|
||||
| | Art. 6 Abs. 1 lit. e DSGVO — Wahrnehmung einer im öffentlichen Interesse liegenden Aufgabe |
|
||||
| Gesundheitsdaten (Art. 9 Abs. 1 DSGVO) | Art. 9 Abs. 2 lit. c DSGVO — Schutz lebenswichtiger Interessen einer Person, die außerstande ist, ihre Einwilligung zu geben |
|
||||
| | Art. 9 Abs. 2 lit. f DSGVO — Geltendmachung von Rechtsansprüchen |
|
||||
| | soweit einwilligungsfähig: Art. 9 Abs. 2 lit. a DSGVO |
|
||||
| Protokollierung (Audit-Log) | Art. 6 Abs. 1 lit. c DSGVO i.V.m. Art. 5 Abs. 2 DSGVO (Rechenschaftspflicht) und der Nachweispflicht gegenüber dem Betreuungsgericht |
|
||||
|
||||
---
|
||||
|
||||
## 4. Kategorien betroffener Personen
|
||||
|
||||
- Die betreute Person
|
||||
- Kontaktpersonen: Rechtspfleger, Sachbearbeiter der Sozialleistungsträger,
|
||||
Heim- und Pflegedienstleitung, behandelnde Ärzte, Notare, Makler,
|
||||
Versicherungsvertreter, Steuerberater, Handwerker
|
||||
- Angehörige, soweit sie in Kontaktprotokollen erwähnt werden
|
||||
- Nutzer der Anwendung (Betreuer, Mitleser)
|
||||
|
||||
---
|
||||
|
||||
## 5. Kategorien personenbezogener Daten
|
||||
|
||||
**Zur betreuten Person**
|
||||
|
||||
| Kategorie | Beispiele | Verschlüsselt? |
|
||||
|---|---|---|
|
||||
| Stammdaten | Name, Geburtsdatum, Geburtsort, Anschrift | nein |
|
||||
| Aufenthalt | Einrichtung, Zimmer, Einzugsdatum | nein |
|
||||
| Gerichtsdaten | Aktenzeichen, Bestellungsdatum, Aufgabenkreise | nein |
|
||||
| **Gesundheitsdaten** | Diagnosen, Pflegegrad, Behandlungsverlauf, Krankenhausaufenthalte, Hilfsmittel, Impfungen | **ja** |
|
||||
| **Ermittelter Wille** | Wünsche, Behandlungsvorstellungen | **ja** |
|
||||
| **Versichertennummern** | Kranken-, Renten-, Steuernummer | **ja** |
|
||||
| Finanzdaten | Buchungen, Salden, Vermögenswerte | nein (Beträge) |
|
||||
| **Kontoverbindungen** | IBAN, Vertragsnummern | **ja** |
|
||||
| **Freitexte** | Notizen, Gesprächsinhalte, Begründungen | **ja** |
|
||||
| Dokumente | Behördenschreiben, Bescheide, Verträge, Fotos | Datei im Objektspeicher |
|
||||
|
||||
**Zu Nutzern der Anwendung**
|
||||
|
||||
Name, E-Mail-Adresse, Passwort-Hash (Argon2id), TOTP-Geheimnis
|
||||
(verschlüsselt), Rolle, Zeitpunkt der letzten Anmeldung, pseudonymisierte
|
||||
IP-Adresse (HMAC), User-Agent.
|
||||
|
||||
---
|
||||
|
||||
## 6. Empfänger
|
||||
|
||||
| Empfänger | Anlass | Grundlage |
|
||||
|---|---|---|
|
||||
| Betreuungsgericht [Amtsgericht] | Jahresbericht, Vermögensverzeichnis, Rechnungslegung, Genehmigungsanträge | §§ 1835, 1859, 1863, 1865 BGB |
|
||||
| [Bezirk Oberfranken] | Antrag auf Hilfe zur Pflege, Nachweise | SGB XII |
|
||||
| Kranken- und Pflegekasse | Anträge, Widersprüche | SGB V, SGB XI |
|
||||
| [Hosting-Anbieter] | technischer Betrieb | Auftragsverarbeitung, Art. 28 DSGVO |
|
||||
| [Objektspeicher-Anbieter] | Dateiablage | Auftragsverarbeitung, Art. 28 DSGVO |
|
||||
| Mitleser mit modulweiser Freigabe | Einsicht in freigegebene Bereiche | Entscheidung des Betreuers, dokumentiert im Audit-Log |
|
||||
|
||||
**Auftragsverarbeitungsverträge liegen vor:** ☐ Hosting ☐ Objektspeicher
|
||||
|
||||
---
|
||||
|
||||
## 7. Drittlandübermittlung
|
||||
|
||||
**Keine.** Alle Verarbeitungen finden auf Servern in der Bundesrepublik
|
||||
Deutschland statt. Die Anwendung lädt keine externen Schriften, Skripte,
|
||||
Bilder oder Analysedienste nach; die Content-Security-Policy unterbindet
|
||||
Verbindungen zu fremden Hosts technisch.
|
||||
|
||||
Aktueller Standort: [Rechenzentrum, Ort]
|
||||
|
||||
---
|
||||
|
||||
## 8. Löschfristen
|
||||
|
||||
| Daten | Frist | Begründung |
|
||||
|---|---|---|
|
||||
| Fachdaten der Betreuung | 10 Jahre nach Ende der Betreuung | Aufbewahrung der Rechnungslegungsunterlagen, analog § 147 AO und zur Abwehr von Ansprüchen |
|
||||
| Audit-Log | 10 Jahre | Nachweis gegenüber Gericht und möglichen Miterben |
|
||||
| Papierkorb | 30 Tage, danach endgültige Löschung | technische Umsetzung |
|
||||
| Anmeldeversuche | 30 Tage | nur für die Sperrlogik erforderlich |
|
||||
| Sicherungen | 30 Tage rollierend, Monatsstände dauerhaft | Wiederherstellbarkeit |
|
||||
|
||||
Nach Ende der Betreuung werden die Unterlagen an den Nachfolger oder an die
|
||||
Erben herausgegeben; anschließend erfolgt die Löschung.
|
||||
|
||||
---
|
||||
|
||||
## 9. Technische und organisatorische Maßnahmen (Art. 32 DSGVO)
|
||||
|
||||
**Vertraulichkeit**
|
||||
|
||||
- Zugangskontrolle: E-Mail und Passwort (Argon2id, 19 MiB, t=2, p=1) **plus
|
||||
verpflichtender zweiter Faktor** (TOTP, RFC 6238). Der zweite Faktor lässt
|
||||
sich nicht abschalten.
|
||||
- Rate-Limiting am Login nach Kennung und Anschluss; Sperre nach 5
|
||||
Fehlversuchen in 15 Minuten.
|
||||
- Sitzungsablauf nach 30 Minuten Leerlauf; erneute Anmeldung vor Export,
|
||||
endgültigem Löschen und Rollenänderungen.
|
||||
- Rollen- und modulbezogene Zugriffskontrolle, serverseitig gegen die
|
||||
Betreuten-ID geprüft.
|
||||
- Transportverschlüsselung (TLS 1.2+) durch den vorgeschalteten Proxy.
|
||||
- **Verschlüsselung im Ruhezustand auf Feldebene** mit AES-256-GCM für
|
||||
Gesundheitsdaten, Kontoverbindungen und Freitexte. Der Schlüssel liegt in
|
||||
der Serverumgebung, nicht in der Datenbank.
|
||||
|
||||
**Integrität**
|
||||
|
||||
- Unveränderliches Audit-Log; UPDATE und DELETE werden auf Datenbankebene
|
||||
durch Trigger abgewiesen.
|
||||
- SHA-256-Prüfsummen über hochgeladene Dokumente.
|
||||
- Historisierung von Vermögens- und Kontoständen; Werte werden nie
|
||||
überschrieben.
|
||||
- Eingabeprüfung serverseitig mit Zod; Prüfung von Uploads an den Magic
|
||||
Bytes statt an Dateiendung oder gemeldetem MIME-Typ.
|
||||
|
||||
**Verfügbarkeit**
|
||||
|
||||
- Tägliche verschlüsselte Sicherung mit Selbstprüfung, Aufbewahrung 30 Tage,
|
||||
Monatsstände dauerhaft.
|
||||
- Dokumentierte Wiederherstellungsanleitung; jährliche Wiederherstellungsübung.
|
||||
- Ein-Klick-Vollexport (ZIP mit Dateien, Daten und Berichten), damit kein
|
||||
Datensilo entsteht.
|
||||
|
||||
**Belastbarkeit und Überprüfung**
|
||||
|
||||
- Automatisierte Tests der Fachlogik und der kritischen Abläufe.
|
||||
- Strenge Content-Security-Policy mit Nonce je Anfrage.
|
||||
- Keine externen Ressourcen, keine Analysedienste, keine Tracker.
|
||||
|
||||
---
|
||||
|
||||
## 10. Datenschutz-Folgenabschätzung
|
||||
|
||||
Eine Folgenabschätzung nach Art. 35 DSGVO wurde geprüft. Es werden
|
||||
umfangreich besondere Kategorien personenbezogener Daten nach Art. 9 DSGVO
|
||||
verarbeitet (Gesundheitsdaten). Die Verarbeitung erfolgt jedoch durch eine
|
||||
einzelne Person für **eine** betreute Person, ohne Profilbildung, ohne
|
||||
automatisierte Entscheidungen im Sinne des Art. 22 DSGVO und ohne
|
||||
systematische Überwachung.
|
||||
|
||||
Ergebnis: [Eine Folgenabschätzung wird als nicht erforderlich angesehen. /
|
||||
Eine Folgenabschätzung wurde durchgeführt, siehe Anlage.]
|
||||
|
||||
---
|
||||
|
||||
*Erstellt am: [Datum] · Zuletzt geprüft am: [Datum]*
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Taegliche Sicherung der Datenbank
|
||||
# =============================================================================
|
||||
#
|
||||
# Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand:
|
||||
#
|
||||
# docker compose exec sicherung /bin/sh /sicherung.sh
|
||||
#
|
||||
# Was passiert:
|
||||
# 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen
|
||||
# wiederherstellbar bleiben.
|
||||
# 2. Verschluesselung mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
|
||||
# Das Backup liegt oft auf fremdem Speicher - unverschluesselt waere
|
||||
# es ein Datenleck mit Ansage.
|
||||
# 3. SHA-256-Pruefsumme daneben.
|
||||
# 4. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
|
||||
# Der jeweils erste Stand eines Monats bleibt stehen.
|
||||
#
|
||||
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder, aber NICHT
|
||||
# den Schluessel ENC_KEY_V1. Ohne ihn sind Diagnosen, IBANs und Notizen
|
||||
# auch aus einer wiederhergestellten Datenbank nicht lesbar. Den Schluessel
|
||||
# getrennt sichern - Passwortmanager oder ausgedruckt im Ordner.
|
||||
# =============================================================================
|
||||
|
||||
set -eu
|
||||
|
||||
ZIEL="${SICHERUNG_ZIEL:-/backups}"
|
||||
AUFBEWAHRUNG="${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}"
|
||||
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)"
|
||||
TAG_IM_MONAT="$(date +%d)"
|
||||
DATEI="$ZIEL/cockpit_${ZEITSTEMPEL}.dump.enc"
|
||||
|
||||
if [ -z "${SICHERUNG_PASSWORT:-}" ]; then
|
||||
echo "$(date -Iseconds) FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt. Abbruch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ZIEL"
|
||||
|
||||
echo "$(date -Iseconds) Sicherung beginnt: $DATEI"
|
||||
|
||||
# pg_dump schreibt direkt in die Verschluesselung - der Klartext landet
|
||||
# nie auf der Platte.
|
||||
pg_dump --format=custom --compress=6 --no-owner --no-privileges \
|
||||
| openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt \
|
||||
-pass env:SICHERUNG_PASSWORT \
|
||||
-out "$DATEI"
|
||||
|
||||
GROESSE="$(du -h "$DATEI" | cut -f1)"
|
||||
sha256sum "$DATEI" > "$DATEI.sha256"
|
||||
|
||||
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
|
||||
# beim Aufraeumen verschont.
|
||||
if [ "$TAG_IM_MONAT" = "01" ]; then
|
||||
touch "$DATEI.monatsstand"
|
||||
fi
|
||||
|
||||
echo "$(date -Iseconds) Sicherung fertig ($GROESSE)"
|
||||
|
||||
# --- Aufraeumen --------------------------------------------------------------
|
||||
ENTFERNT=0
|
||||
for alt in "$ZIEL"/cockpit_*.dump.enc; do
|
||||
[ -e "$alt" ] || continue
|
||||
[ -e "$alt.monatsstand" ] && continue
|
||||
|
||||
if [ -n "$(find "$alt" -mtime "+$AUFBEWAHRUNG" 2>/dev/null)" ]; then
|
||||
rm -f "$alt" "$alt.sha256"
|
||||
ENTFERNT=$((ENTFERNT + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$ENTFERNT" -gt 0 ] && echo "$(date -Iseconds) $ENTFERNT alte Staende entfernt"
|
||||
|
||||
# --- Selbstpruefung ----------------------------------------------------------
|
||||
# Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup.
|
||||
# Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist.
|
||||
if openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
|
||||
| head -c 5 | grep -q "PGDMP"; then
|
||||
echo "$(date -Iseconds) Selbstpruefung bestanden."
|
||||
else
|
||||
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Sicherung zurueckspielen
|
||||
# =============================================================================
|
||||
#
|
||||
# docker compose exec sicherung /bin/sh /wiederherstellung.sh /backups/cockpit_2026-08-22_0315.dump.enc
|
||||
#
|
||||
# Das Skript spielt eine verschluesselte Sicherung zurueck. Es fragt
|
||||
# ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben
|
||||
# wird.
|
||||
#
|
||||
# ZWEI DINGE, DIE MAN VORHER WISSEN MUSS:
|
||||
#
|
||||
# 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder,
|
||||
# aber NICHT den Schluessel. Ohne den passenden ENC_KEY_Vn aus der .env
|
||||
# bleiben Diagnosen, IBANs, Versicherungsnummern und Notizen unlesbar -
|
||||
# die Datenbank ist dann zwar da, aber halb blind.
|
||||
#
|
||||
# 2. Ein Backup, das nie zurueckgespielt wurde, ist kein Backup. Probiere
|
||||
# das hier einmal im Jahr auf einer Testdatenbank aus, bevor du es im
|
||||
# Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt
|
||||
# "Wiederherstellung ueben".
|
||||
# =============================================================================
|
||||
|
||||
set -eu
|
||||
|
||||
DATEI="${1:-}"
|
||||
|
||||
if [ -z "$DATEI" ]; then
|
||||
echo "Aufruf: $0 <pfad-zur-sicherung.dump.enc>" >&2
|
||||
echo "" >&2
|
||||
echo "Vorhandene Sicherungen:" >&2
|
||||
ls -lh "${SICHERUNG_ZIEL:-/backups}"/cockpit_*.dump.enc 2>/dev/null >&2 || echo " keine gefunden" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$DATEI" ]; then
|
||||
echo "FEHLER: $DATEI gibt es nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${SICHERUNG_PASSWORT:-}" ]; then
|
||||
echo "FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Pruefsumme -------------------------------------------------------------
|
||||
if [ -f "$DATEI.sha256" ]; then
|
||||
echo "Pruefsumme wird geprueft ..."
|
||||
if sha256sum -c "$DATEI.sha256" >/dev/null 2>&1; then
|
||||
echo " in Ordnung."
|
||||
else
|
||||
echo "FEHLER: Die Pruefsumme stimmt nicht. Die Datei ist beschaedigt." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNUNG: Keine Pruefsummendatei gefunden - es wird ungeprueft fortgefahren."
|
||||
fi
|
||||
|
||||
# --- Sicherheitsabfrage ------------------------------------------------------
|
||||
echo ""
|
||||
echo "Es wird zurueckgespielt nach:"
|
||||
echo " Datenbank: ${PGDATABASE:-?} auf ${PGHOST:-?}"
|
||||
echo " Aus Datei: $DATEI"
|
||||
echo ""
|
||||
echo "DER AKTUELLE DATENBESTAND WIRD DABEI UEBERSCHRIEBEN."
|
||||
echo ""
|
||||
|
||||
if [ "${SICHERUNG_OHNE_RUECKFRAGE:-}" != "ja" ]; then
|
||||
printf "Zum Fortfahren bitte JA eingeben: "
|
||||
read -r antwort
|
||||
if [ "$antwort" != "JA" ]; then
|
||||
echo "Abgebrochen."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Zurueckspielen ----------------------------------------------------------
|
||||
echo ""
|
||||
echo "$(date -Iseconds) Wiederherstellung beginnt ..."
|
||||
|
||||
# --clean --if-exists raeumt vorhandene Objekte ab, bevor sie neu angelegt
|
||||
# werden. --no-owner, weil der Eigentuemer auf einem anderen Server anders
|
||||
# heissen kann.
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:SICHERUNG_PASSWORT -in "$DATEI" \
|
||||
| pg_restore --clean --if-exists --no-owner --no-privileges \
|
||||
--dbname="${PGDATABASE:-postgres}" --exit-on-error
|
||||
|
||||
echo "$(date -Iseconds) Wiederherstellung abgeschlossen."
|
||||
echo ""
|
||||
echo "Naechste Schritte:"
|
||||
echo " 1. Die SQL-Skripte erneut ausfuehren (Volltextsuche und Audit-Schutz):"
|
||||
echo " docker compose exec app npm run db:sql"
|
||||
echo " 2. Pruefen, ob ENC_KEY_V1 in der .env zu diesem Bestand passt -"
|
||||
echo " ohne den richtigen Schluessel bleiben die verschluesselten"
|
||||
echo " Felder unlesbar."
|
||||
echo " 3. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
|
||||
echo " die Diagnosen im Klartext erscheinen. Erst dann ist die"
|
||||
echo " Wiederherstellung wirklich gelungen."
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, Loader2, ShieldCheck } from 'lucide-react'
|
||||
import { anmeldungBestaetigen, type BestaetigenZustand } from '@/server/actions/bestaetigen'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Feld } from '@/components/feld'
|
||||
|
||||
function Absenden() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" className="w-full" size="lg" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <ShieldCheck aria-hidden="true" />}
|
||||
{pending ? 'Wird geprüft …' : 'Bestätigen'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BestaetigenFormular({ weiter }: { weiter: string }) {
|
||||
const [zustand, aktion] = useActionState<BestaetigenZustand, FormData>(anmeldungBestaetigen, {})
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
<input type="hidden" name="weiter" value={weiter} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Feld id="passwort" beschriftung="Passwort" pflicht>
|
||||
<Input name="passwort" type="password" autoComplete="current-password" required autoFocus />
|
||||
</Feld>
|
||||
|
||||
<Feld id="code" beschriftung="Code aus der Authenticator-App" pflicht>
|
||||
<Input
|
||||
name="code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="123456"
|
||||
className="text-center font-mono text-lg tracking-[0.4em]"
|
||||
required
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Absenden />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-md space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kurz bestätigen, bitte</CardTitle>
|
||||
<CardDescription>
|
||||
Für diesen Schritt brauchst du deine Zugangsdaten noch einmal.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BestaetigenFormular weiter={weiter} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Alert variant="hinweis">
|
||||
<ShieldAlert aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
Exportieren, endgültiges Löschen und die Benutzerverwaltung verlangen eine frische
|
||||
Anmeldung. Der wahrscheinlichste Angriffsweg ist nicht der Einbruch in den Server,
|
||||
sondern ein unbeaufsichtigter Rechner mit offener Sitzung.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Dokument aufnehmen"
|
||||
beschreibung="Foto oder PDF – der Text wird danach automatisch gelesen und du bekommst Absender, Datum, Aktenzeichen und mögliche Fristen als Vorschlag."
|
||||
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Aufnehmen' }]}
|
||||
/>
|
||||
|
||||
<Alert variant="hinweis">
|
||||
<ShieldCheck aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
Erlaubt sind PDF, JPEG, PNG, WEBP, TIFF und HEIC, bis{' '}
|
||||
{formatBytes(env.uploadMaxBytes)} je Datei. Geprüft wird der Inhalt, nicht die Endung –
|
||||
eine Datei, die sich nur als PDF ausgibt, wird abgelehnt. Die Dateien verlassen diesen
|
||||
Server nicht.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<UploadFormular
|
||||
vorgaenge={vorgaenge}
|
||||
vorgangVorgewaehlt={einzeln('vorgang') ?? null}
|
||||
kameraZuerst={einzeln('quelle') === 'kamera'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Dokumente"
|
||||
beschreibung="Die Akte in digitaler Form. Suche findet auch Text aus abfotografierten Schreiben."
|
||||
pfad={[{ titel: 'Dokumente' }]}
|
||||
aktionen={
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dokumente/posteingang">
|
||||
<Inbox aria-hidden="true" />
|
||||
Posteingang
|
||||
{neuImPosteingang > 0 && (
|
||||
<span className="ml-1 rounded-full bg-ampel-orange/15 px-2 py-0.5 text-xs font-semibold text-ampel-orange">
|
||||
{neuImPosteingang}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
{darfSchreiben('DOKUMENTE') && (
|
||||
<Button asChild>
|
||||
<Link href="/dokumente/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Dokument aufnehmen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterLeiste
|
||||
werte={{
|
||||
suche: filter.suche,
|
||||
art: filter.art,
|
||||
richtung: filter.richtung,
|
||||
status: filter.posteingangStatus,
|
||||
von: einzelnerParameter(parameter.von),
|
||||
bis: einzelnerParameter(parameter.bis),
|
||||
tag: filter.tagId,
|
||||
}}
|
||||
tags={tags}
|
||||
/>
|
||||
|
||||
{eintraege.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={FileText}
|
||||
titel={gefiltert ? 'Kein Dokument passt zu diesen Filtern' : 'Noch keine Dokumente'}
|
||||
beschreibung={
|
||||
gefiltert
|
||||
? 'Setze die Filter zurück oder suche mit weniger Wörtern. Die Volltextsuche findet nur Dokumente, deren Text schon erkannt wurde.'
|
||||
: 'Fotografiere den nächsten Brief, der im Kasten liegt – der Rest geht fast von selbst.'
|
||||
}
|
||||
aktion={
|
||||
darfSchreiben('DOKUMENTE') && (
|
||||
<Button asChild>
|
||||
<Link href="/dokumente/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Erstes Dokument aufnehmen
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{gesamt} {gesamt === 1 ? 'Dokument' : 'Dokumente'}
|
||||
{gefiltert ? ' passen zu den Filtern' : ' in der Akte'}
|
||||
{seiten > 1 ? ` · Seite ${filter.seite} von ${seiten}` : ''}
|
||||
</p>
|
||||
|
||||
<DokumentListe eintraege={eintraege} />
|
||||
|
||||
{seiten > 1 && (
|
||||
<Blaettern aktuelleSeite={filter.seite} seiten={seiten} parameter={parameter} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="space-y-3 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<BookOpen className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Belegmappe für Gericht und Bezirk</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Alle Belege eines Zeitraums oder eines Vorgangs als eine chronologisch sortierte PDF –
|
||||
mit Deckblatt, Inhaltsverzeichnis und durchgehender Seitenzahl.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BelegmappeFormular vorgaenge={vorgaenge} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string, string | string[] | undefined>
|
||||
}) {
|
||||
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 (
|
||||
<nav className="flex items-center justify-between gap-3" aria-label="Blättern">
|
||||
<Button variant="outline" disabled={aktuelleSeite <= 1} asChild={aktuelleSeite > 1}>
|
||||
{aktuelleSeite > 1 ? <Link href={adresse(aktuelleSeite - 1)}>Zurück</Link> : <span>Zurück</span>}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Seite {aktuelleSeite} von {seiten}
|
||||
</span>
|
||||
<Button variant="outline" disabled={aktuelleSeite >= seiten} asChild={aktuelleSeite < seiten}>
|
||||
{aktuelleSeite < seiten ? (
|
||||
<Link href={adresse(aktuelleSeite + 1)}>Weiter</Link>
|
||||
) : (
|
||||
<span>Weiter</span>
|
||||
)}
|
||||
</Button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Posteingang"
|
||||
beschreibung="Was ist angekommen, was habe ich gesehen, was ist zugeordnet, was beantwortet? Nichts soll durchrutschen."
|
||||
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Posteingang' }]}
|
||||
aktionen={
|
||||
schreibrecht && (
|
||||
<Button asChild>
|
||||
<Link href="/dokumente/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Post aufnehmen
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{dokumente.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={Inbox}
|
||||
titel="Der Posteingang ist leer"
|
||||
beschreibung="Sobald du einen Brief fotografierst oder ein PDF hochlädst, taucht er hier auf."
|
||||
aktion={
|
||||
schreibrecht && (
|
||||
<Button asChild>
|
||||
<Link href="/dokumente/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Post aufnehmen
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||
{POSTEINGANG_STUFEN.map((stufe) => (
|
||||
<section key={stufe} className="flex flex-col rounded-lg border bg-card">
|
||||
<header className="border-b px-4 py-3">
|
||||
<h2 className="flex items-center justify-between gap-2 font-semibold">
|
||||
{POSTEINGANG_BESCHRIFTUNG[stufe]}
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs tabular-nums text-muted-foreground">
|
||||
{nachStufe[stufe].length}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{POSTEINGANG_ERKLAERUNG[stufe]}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{nachStufe[stufe].length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Nichts auf dieser Stufe.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{nachStufe[stufe].map((dokument) => (
|
||||
<li key={dokument.id} className="space-y-2 px-4 py-3">
|
||||
<Link
|
||||
href={`/dokumente/${dokument.id}`}
|
||||
className="block font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{dokument.titel}
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dokument.absender ?? 'Ohne Absender'} ·{' '}
|
||||
{formatDatum(dokument.eingangAm ?? dokument.schreibenVom)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<ArtAbzeichen art={dokument.art} />
|
||||
<RichtungAbzeichen richtung={dokument.richtung} />
|
||||
<OcrAbzeichen status={dokument.ocrStatus} />
|
||||
</div>
|
||||
{dokument._count.verknuepfungen === 0 && stufe !== 'NEU' && (
|
||||
<p className="text-xs text-ampel-orange">
|
||||
Noch keinem Vorgang zugeordnet.
|
||||
</p>
|
||||
)}
|
||||
<PosteingangStufen
|
||||
dokumentId={dokument.id}
|
||||
status={dokument.posteingangStatus}
|
||||
schreibrecht={schreibrecht}
|
||||
kompakt
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
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 (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<SeitenKopf
|
||||
titel={schnell ? 'Schnellerfassung' : 'Buchung erfassen'}
|
||||
beschreibung={
|
||||
schnell
|
||||
? 'Das Formular bleibt nach dem Speichern offen. Datum und Konto musst du je Buchung neu wählen – dafür wanderst du zügig durch den Kontoauszug.'
|
||||
: 'Beträge immer positiv eintragen; ob Geld kommt oder geht, sagt die Richtung.'
|
||||
}
|
||||
pfad={[
|
||||
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
|
||||
{ titel: 'Kassenbuch', href: '/finanzen/buchungen' },
|
||||
{ titel: schnell ? 'Schnellerfassung' : 'Neu' },
|
||||
]}
|
||||
aktionen={
|
||||
<Button asChild variant="ghost">
|
||||
<Link href={schnell ? '/finanzen/buchungen/neu' : '/finanzen/buchungen/neu?schnell=1'}>
|
||||
{schnell ? 'Zur normalen Erfassung' : 'Zur Schnellerfassung'}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{konten.length === 0 && (
|
||||
<Alert variant="warnung">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Es gibt noch kein Konto</AlertTitle>
|
||||
<AlertDescription>
|
||||
Du kannst eine Buchung auch ohne Konto erfassen, aber dann lässt sie sich später
|
||||
keinem Kontostand zuordnen – und die Rechenprobe der Rechnungslegung geht nicht auf.{' '}
|
||||
<Link href="/finanzen/konten/neu" className="underline">
|
||||
Erst ein Konto anlegen
|
||||
</Link>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Buchungsformular
|
||||
betreuteId={betreute.id}
|
||||
konten={
|
||||
kontoId
|
||||
? [
|
||||
...konten.filter((k) => k.id === kontoId),
|
||||
...konten.filter((k) => k.id !== kontoId),
|
||||
]
|
||||
: konten
|
||||
}
|
||||
belege={belege}
|
||||
schnellerfassung={schnell}
|
||||
standardDatum={tagAlsText(heute())}
|
||||
/>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const roh = await searchParams
|
||||
const { betreute, darfSchreiben } = await finanzKontext()
|
||||
const schreiben = darfSchreiben('FINANZEN')
|
||||
|
||||
const flach: Record<string, string> = {}
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Kassenbuch"
|
||||
beschreibung="Jede Einnahme und jede Ausgabe. Aus diesen Zeilen entsteht die Rechnungslegung – deshalb gehört zu jeder Zeile ein Beleg oder eine Begründung, warum keiner da ist."
|
||||
pfad={[{ titel: 'Vermögen & Kasse', href: '/finanzen' }, { titel: 'Kassenbuch' }]}
|
||||
aktionen={
|
||||
schreiben ? (
|
||||
<>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/finanzen/buchungen/import">
|
||||
<Upload aria-hidden="true" />
|
||||
Importieren
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/finanzen/buchungen/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Buchung erfassen
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Buchungsfilter
|
||||
konten={konten}
|
||||
aktiv={{
|
||||
kontoId: filter.kontoId ?? '',
|
||||
kategorie: filter.kategorie ?? '',
|
||||
richtung: filter.richtung ?? '',
|
||||
von: filter.von ? filter.von.toISOString().slice(0, 10) : '',
|
||||
bis: filter.bis ? filter.bis.toISOString().slice(0, 10) : '',
|
||||
suche: filter.suche ?? '',
|
||||
nurOhneBeleg: filter.nurOhneBeleg,
|
||||
nurEigeneAuslagen: filter.nurEigeneAuslagen,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Kennzahl titel="Buchungen in der Auswahl" wert={gesamt} />
|
||||
<Kennzahl
|
||||
titel="Einnahmen"
|
||||
wert={formatCent(centZuZahl(zahlen.einnahmenCent))}
|
||||
hinweis="ohne Umbuchungen und eigene Auslagen"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Ausgaben"
|
||||
wert={formatCent(centZuZahl(zahlen.ausgabenCent))}
|
||||
hinweis="ohne Umbuchungen und eigene Auslagen"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Ohne Beleg und ohne Begründung"
|
||||
wert={zahlen.beleg.ohneNachweis}
|
||||
hinweis={
|
||||
zahlen.beleg.ohneNachweis > 0
|
||||
? 'Genau danach fragt der Rechtspfleger.'
|
||||
: 'Da fehlt nichts.'
|
||||
}
|
||||
ton={zahlen.beleg.ohneNachweis > 0 ? 'achtung' : 'gut'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{zahlen.vermischungen > 0 && <Vermischungswarnung />}
|
||||
|
||||
{buchungen.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={Banknote}
|
||||
titel={gesamt === 0 ? 'Noch keine Buchung erfasst' : 'Keine Buchung passt zu diesem Filter'}
|
||||
beschreibung={
|
||||
gesamt === 0
|
||||
? 'Erfasse die erste Buchung von Hand oder lies einen Kontoauszug als CSV ein.'
|
||||
: 'Setz den Filter zurück oder wähl einen anderen Zeitraum.'
|
||||
}
|
||||
aktion={
|
||||
schreiben && gesamt === 0 ? (
|
||||
<Button asChild>
|
||||
<Link href="/finanzen/buchungen/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Buchung erfassen
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Vorgang</TableHead>
|
||||
<TableHead>Konto</TableHead>
|
||||
<TableHead>Beleg</TableHead>
|
||||
<TableHead className="text-right">Betrag</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{buchungen.map((buchung) => (
|
||||
<TableRow
|
||||
key={buchung.id}
|
||||
className={belegFehlt(buchung) ? 'bg-ampel-rot/5' : undefined}
|
||||
>
|
||||
<TableCell className="whitespace-nowrap align-top">
|
||||
{formatDatum(buchung.datum)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Link
|
||||
href={`/finanzen/buchungen/${buchung.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{buchung.gegenstelle ?? KATEGORIE_BESCHRIFTUNG[buchung.kategorie]}
|
||||
</Link>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{RICHTUNG_BESCHRIFTUNG[buchung.richtung]} ·{' '}
|
||||
{KATEGORIE_BESCHRIFTUNG[buchung.kategorie]}
|
||||
</span>
|
||||
{buchung.verwendungszweck && (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{buchung.verwendungszweck}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 flex flex-wrap gap-1.5">
|
||||
{!buchung.betrifftBetreute && (
|
||||
<Badge variant="secondary">eigene Auslage</Badge>
|
||||
)}
|
||||
{buchung.vermoegenswirksam && (
|
||||
<Badge variant="outline">vermögenswirksam</Badge>
|
||||
)}
|
||||
{vermischungsWarnung(buchung) && <Vermischungswarnung kurz />}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-sm text-muted-foreground">
|
||||
{buchung.kontoBezeichnung ?? 'kein Konto'}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Belegzeichen
|
||||
belegId={buchung.belegId}
|
||||
belegTitel={buchung.belegTitel ?? null}
|
||||
begruendung={buchung.belegFehltBegruendung}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right align-top">
|
||||
<Betrag cent={centZuZahl(buchung.betragCent)} richtung={buchung.richtung} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{gesamt > buchungen.length && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Es werden die {HOECHSTZAHL} jüngsten von {gesamt} Buchungen gezeigt. Grenze den
|
||||
Zeitraum ein, um die älteren zu sehen.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel={konto.bezeichnung}
|
||||
beschreibung={
|
||||
<span className="space-y-1">
|
||||
<span className="block">
|
||||
{KONTO_ART_BESCHRIFTUNG[konto.art]}
|
||||
{konto.bank ? ` · ${konto.bank}` : ''}
|
||||
</span>
|
||||
{offen.iban && (
|
||||
<span className="block font-mono text-sm">{ibanFormatieren(offen.iban)}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
pfad={[
|
||||
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
|
||||
{ titel: 'Konten', href: '/finanzen/konten' },
|
||||
{ titel: konto.bezeichnung },
|
||||
]}
|
||||
aktionen={
|
||||
<Button asChild variant="ghost">
|
||||
<Link href={`/finanzen/buchungen?kontoId=${konto.id}`}>
|
||||
Buchungen dieses Kontos
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{konto.istBetreuerkonto && (
|
||||
<Badge variant="orange">
|
||||
Konto des Betreuers – gehört nicht zum Vermögen der betreuten Person
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontostände</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{konto.saldostaende.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
Noch kein Kontostand erfasst. Ohne Stand bleibt dieses Konto in der Rechnungslegung
|
||||
mit 0,00 € stehen – und die Rechenprobe geht nicht auf.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stichtag</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead>Quelle</TableHead>
|
||||
{schreiben && <TableHead className="text-right">Aktion</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{konto.saldostaende.map((stand) => (
|
||||
<TableRow key={stand.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDatum(stand.stichtag)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Betrag cent={centZuZahl(stand.betragCent)} fett />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{stand.quelle ?? 'keine Quelle angegeben'}
|
||||
</TableCell>
|
||||
{schreiben && (
|
||||
<TableCell className="text-right">
|
||||
<Loeschknopf
|
||||
aktion={saldostandLoeschen}
|
||||
felder={{ betreuteId: betreute.id, saldostandId: stand.id }}
|
||||
frage={`Kontostand vom ${formatDatum(stand.stichtag)} endgültig entfernen? Er wird nicht in den Papierkorb gelegt.`}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{schreiben && (
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="mb-3 text-base font-semibold">Neuen Kontostand festhalten</h3>
|
||||
<Saldoformular
|
||||
betreuteId={betreute.id}
|
||||
konten={[{ id: konto.id, bezeichnung: konto.bezeichnung }]}
|
||||
kontoId={konto.id}
|
||||
standardStichtag={tagAlsText(heute())}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Letzte Buchungen</CardTitle>
|
||||
<CardDescription>
|
||||
{konto._count.buchungen} Buchung(en) insgesamt auf diesem Konto.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{buchungen.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Noch keine Buchungen auf diesem Konto.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Gegenstelle</TableHead>
|
||||
<TableHead className="text-right">Betrag</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{buchungen.map((buchung) => (
|
||||
<TableRow key={buchung.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDatum(buchung.datum)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/finanzen/buchungen/${buchung.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{buchung.gegenstelle ?? 'ohne Gegenstelle'}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Betrag cent={centZuZahl(buchung.betragCent)} richtung={buchung.richtung} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
{schreiben && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stammdaten ändern</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<Kontoformular
|
||||
betreuteId={betreute.id}
|
||||
konto={{
|
||||
id: konto.id,
|
||||
bezeichnung: konto.bezeichnung,
|
||||
bank: konto.bank,
|
||||
art: konto.art,
|
||||
iban: offen.iban,
|
||||
bic: konto.bic,
|
||||
istBetreuerkonto: konto.istBetreuerkonto,
|
||||
eroeffnetAm: tagAlsText(konto.eroeffnetAm) || null,
|
||||
geschlossenAm: tagAlsText(konto.geschlossenAm) || null,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<Loeschknopf
|
||||
aktion={kontoLoeschen}
|
||||
felder={{ betreuteId: betreute.id, kontoId: konto.id }}
|
||||
beschriftung="Konto in den Papierkorb"
|
||||
variante="outline"
|
||||
frage="Konto in den Papierkorb legen? Es bleibt 30 Tage wiederherstellbar. Konten mit Buchungen werden nicht gelöscht – trage stattdessen ein Schließungsdatum ein."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 1835 BGB" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Konto anlegen"
|
||||
beschreibung="Die IBAN wird verschlüsselt gespeichert. Zusätzlich wird ein Blindindex abgelegt – damit erkennt die Anwendung ein doppelt angelegtes Konto, ohne die IBAN im Klartext vorzuhalten."
|
||||
pfad={[
|
||||
{ titel: 'Vermögen & Kasse', href: '/finanzen' },
|
||||
{ titel: 'Konten', href: '/finanzen/konten' },
|
||||
{ titel: 'Neu' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Kontoformular betreuteId={betreute.id} />
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 1835 BGB" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Konten"
|
||||
beschreibung="Alle Konten und Kassen, über die Geld der betreuten Person läuft – einschließlich Verwahrgeld im Heim und Barkasse."
|
||||
pfad={[{ titel: 'Vermögen & Kasse', href: '/finanzen' }, { titel: 'Konten' }]}
|
||||
aktionen={
|
||||
schreiben ? (
|
||||
<Button asChild>
|
||||
<Link href="/finanzen/konten/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Konto anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{konten.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={Landmark}
|
||||
titel="Noch kein Konto erfasst"
|
||||
beschreibung="Ohne Konten gibt es keine Bestände – und ohne Bestände keine Rechnungslegung. Fang mit dem Girokonto an."
|
||||
aktion={
|
||||
schreiben ? (
|
||||
<Button asChild>
|
||||
<Link href="/finanzen/konten/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Konto anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Konto</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Bank</TableHead>
|
||||
<TableHead>Stand vom</TableHead>
|
||||
<TableHead className="text-right">Betrag</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{staende.map((stand) => (
|
||||
<TableRow key={stand.kontoId}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/finanzen/konten/${stand.kontoId}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{stand.bezeichnung}
|
||||
</Link>
|
||||
{stand.ibanMaskiert && (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{stand.ibanMaskiert}
|
||||
</span>
|
||||
)}
|
||||
{stand.istBetreuerkonto && (
|
||||
<Badge variant="orange" className="mt-1">
|
||||
Konto des Betreuers
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{stand.artBeschriftung}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{stand.bank ?? '–'}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{stand.stichtag ? formatDatum(stand.stichtag) : 'nie erfasst'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Betrag cent={centZuZahl(stand.betragCent)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<p className="flex items-baseline justify-between gap-4 border-t pt-3 font-semibold">
|
||||
<span>Summe der Konten der betreuten Person</span>
|
||||
<span className="tabular-nums">{formatCent(centZuZahl(summeCent))}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Konten des Betreuers sind in dieser Summe nicht enthalten – sie gehören nicht zum
|
||||
Vermögen der betreuten Person.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel="Vermögen & Kasse"
|
||||
beschreibung={
|
||||
jahr
|
||||
? `Aktuell läuft das ${jahr.bezeichnung}. Alles hier fließt in das Vermögensverzeichnis und die Rechnungslegung.`
|
||||
: 'Konten, Kassenbuch und Vermögensposten. Von hier aus entstehen beide Berichte ans Betreuungsgericht.'
|
||||
}
|
||||
pfad={[{ titel: 'Vermögen & Kasse' }]}
|
||||
aktionen={
|
||||
schreiben ? (
|
||||
<>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/finanzen/buchungen/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Buchung erfassen
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/finanzen/berichte">
|
||||
<FileDown aria-hidden="true" />
|
||||
Berichte
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Kennzahl
|
||||
titel="Guthaben auf Konten"
|
||||
wert={formatCent(centZuZahl(summeKontenCent))}
|
||||
hinweis={`${staende.length} Konto/Konten der betreuten Person`}
|
||||
href="/finanzen/konten"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Reinvermögen"
|
||||
wert={formatCent(centZuZahl(summeKontenCent + vermoegen.summeAktivaCent - vermoegen.summePassivaCent))}
|
||||
hinweis="Konten und Vermögensposten, abzüglich Verbindlichkeiten"
|
||||
href="/finanzen/vermoegen"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Belegquote (12 Monate)"
|
||||
wert={`${zahlen.beleg.quoteProzent} %`}
|
||||
hinweis={
|
||||
zahlen.beleg.ohneNachweis > 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"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Saldo der letzten 12 Monate"
|
||||
wert={formatCent(centZuZahl(zahlen.saldoCent))}
|
||||
hinweis={`${formatCent(centZuZahl(zahlen.einnahmenCent))} ein, ${formatCent(centZuZahl(zahlen.ausgabenCent))} aus`}
|
||||
ton={zahlen.saldoCent < 0n ? 'achtung' : 'neutral'}
|
||||
href="/finanzen/buchungen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{zahlen.vermischungen > 0 && <Vermischungswarnung />}
|
||||
|
||||
<Hinweisliste hinweise={hinweise} titel="Das solltest du nachtragen" />
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Konten</CardTitle>
|
||||
<CardDescription>
|
||||
Stand zum {formatDatum(stichtag)} – jeweils der jüngste erfasste Saldostand.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{konten.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={Landmark}
|
||||
titel="Noch kein Konto erfasst"
|
||||
beschreibung="Fang mit dem Girokonto an, über das Rente und Heimkosten laufen. Das Verwahrgeldkonto im Heim gehört ebenfalls dazu."
|
||||
aktion={
|
||||
schreiben ? (
|
||||
<Button asChild>
|
||||
<Link href="/finanzen/konten/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Konto anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Konto</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Stand vom</TableHead>
|
||||
<TableHead className="text-right">Betrag</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{kontostaendeZumStichtag(konten, stichtag).map((stand) => (
|
||||
<TableRow key={stand.kontoId}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/finanzen/konten/${stand.kontoId}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{stand.bezeichnung}
|
||||
</Link>
|
||||
{stand.istBetreuerkonto && (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Konto des Betreuers – zählt nicht zum Vermögen der Betreuten
|
||||
</span>
|
||||
)}
|
||||
{stand.ibanMaskiert && (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{stand.ibanMaskiert}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{stand.artBeschriftung}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{stand.stichtag ? formatDatum(stand.stichtag) : 'nie erfasst'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Betrag cent={centZuZahl(stand.betragCent)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Zuletzt gebucht</CardTitle>
|
||||
<CardDescription>Die acht jüngsten Einträge im Kassenbuch.</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{schreiben && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/finanzen/buchungen/import">
|
||||
<Upload aria-hidden="true" />
|
||||
Importieren
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/finanzen/buchungen">
|
||||
Alle ansehen
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{letzte.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={Banknote}
|
||||
titel="Das Kassenbuch ist noch leer"
|
||||
beschreibung="Erfasse die erste Buchung von Hand oder lies einen Kontoauszug als CSV ein."
|
||||
aktion={
|
||||
schreiben ? (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button asChild>
|
||||
<Link href="/finanzen/buchungen/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Buchung erfassen
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/finanzen/buchungen/import">
|
||||
<Upload aria-hidden="true" />
|
||||
CSV einlesen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Vorgang</TableHead>
|
||||
<TableHead>Beleg</TableHead>
|
||||
<TableHead className="text-right">Betrag</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{letzte.map((buchung) => (
|
||||
<TableRow key={buchung.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDatum(buchung.datum)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/finanzen/buchungen/${buchung.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{buchung.gegenstelle ?? KATEGORIE_BESCHRIFTUNG[buchung.kategorie]}
|
||||
</Link>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{KATEGORIE_BESCHRIFTUNG[buchung.kategorie]}
|
||||
{!buchung.betrifftBetreute && ' · eigene Auslage'}
|
||||
{buchung.vermoegenswirksam && ' · vermögenswirksam'}
|
||||
</span>
|
||||
{vermischungsWarnung(buchung) && (
|
||||
<span className="mt-1 block">
|
||||
<Vermischungswarnung kurz />
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Belegzeichen
|
||||
belegId={buchung.belegId}
|
||||
belegTitel={buchung.belegTitel ?? null}
|
||||
begruendung={buchung.belegFehltBegruendung}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Betrag cent={centZuZahl(buchung.betragCent)} richtung={buchung.richtung} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 1835 BGB, §§ 1859, 1865 BGB" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Hinweise"
|
||||
pfad={[{ titel: 'Hinweise' }]}
|
||||
beschreibung={
|
||||
<>
|
||||
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 && (
|
||||
<Alert variant="warnung">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<AlertTitle>Die Wissensbasis konnte nicht gelesen werden</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
In <code>daten/hinweise.json</code> stimmt etwas nicht. Bis das behoben ist, werden
|
||||
keine Hinweise angezeigt – der Rest der Anwendung funktioniert normal weiter.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{fehler.slice(0, 5).map((meldung) => (
|
||||
<li key={meldung}>{meldung}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<HinweisSuche karten={karten} />
|
||||
|
||||
<Rechtshinweis />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel={`${immobilie.bezeichnung} bearbeiten`}
|
||||
beschreibung="Änderungen werden protokolliert — im Audit-Log steht später, was wann geändert wurde."
|
||||
pfad={[
|
||||
{ titel: 'Übersicht', href: '/heute' },
|
||||
{ titel: 'Immobilie', href: '/immobilie' },
|
||||
{ titel: immobilie.bezeichnung, href: `/immobilie/${id}` },
|
||||
{ titel: 'Bearbeiten' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ImmobilieFormular
|
||||
betreuteId={aktive.id}
|
||||
werte={{
|
||||
id: immobilie.id,
|
||||
bezeichnung: immobilie.bezeichnung,
|
||||
strasse: immobilie.strasse ?? '',
|
||||
plz: immobilie.plz ?? '',
|
||||
ort: immobilie.ort ?? '',
|
||||
grundbuchamt: immobilie.grundbuchamt ?? '',
|
||||
grundbuchBlatt: immobilie.grundbuchBlatt ?? '',
|
||||
gemarkung: immobilie.gemarkung ?? '',
|
||||
flurstueck: immobilie.flurstueck ?? '',
|
||||
eigentumsanteil: immobilie.eigentumsanteil ?? '',
|
||||
wohnflaecheQm: immobilie.wohnflaecheQm?.toString() ?? '',
|
||||
grundstueckQm: immobilie.grundstueckQm?.toString() ?? '',
|
||||
baujahr: immobilie.baujahr?.toString() ?? '',
|
||||
bewohntStatus: immobilie.bewohntStatus ?? '',
|
||||
leerstandSeit: immobilie.leerstandSeit ? alsIsoTag(immobilie.leerstandSeit) : '',
|
||||
notizen: offen.notizen ?? '',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Pflichten bei Leerstand"
|
||||
beschreibung={
|
||||
immobilie.leerstandSeit
|
||||
? `${immobilie.bezeichnung} steht seit ${formatDatum(immobilie.leerstandSeit)} leer.`
|
||||
: `${immobilie.bezeichnung} — Pflichten, die auch ohne Bewohner weiterlaufen.`
|
||||
}
|
||||
pfad={[
|
||||
{ titel: 'Übersicht', href: '/heute' },
|
||||
{ titel: 'Immobilie', href: '/immobilie' },
|
||||
{ titel: immobilie.bezeichnung, href: `/immobilie/${id}` },
|
||||
{ titel: 'Pflichten bei Leerstand' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Alert variant="warnung">
|
||||
<ShieldAlert aria-hidden="true" />
|
||||
<AlertTitle>Der teuerste Fehler zuerst</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Leerstand ist für den Gebäudeversicherer eine Gefahrerhöhung. Wird sie nicht
|
||||
angezeigt, kann der Versicherer im Schadensfall leistungsfrei werden — ein
|
||||
Wasserschaden im leeren Haus geht dann zulasten des Vermögens der betreuten
|
||||
Person. Melde den Leerstand schriftlich, frag ausdrücklich nach den Auflagen
|
||||
(Kontrollgänge, abgestellte Wasserzufuhr) und nimm die Antwort zur Akte.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Checkliste
|
||||
betreuteId={aktive.id}
|
||||
immobilieId={id}
|
||||
darfSchreiben={schreiben}
|
||||
punkte={immobilie.checklistenPunkte.map((punkt) => ({
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 1821 BGB, § 823 BGB, § 23 VVG" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Fotodokumentation und Inventar"
|
||||
beschreibung={`${immobilie.bezeichnung} — was im Haus steht, was es wert ist und wo es geblieben ist.`}
|
||||
pfad={[
|
||||
{ titel: 'Übersicht', href: '/heute' },
|
||||
{ titel: 'Immobilie', href: '/immobilie' },
|
||||
{ titel: immobilie.bezeichnung, href: `/immobilie/${id}` },
|
||||
{ titel: 'Inventar' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Inventar
|
||||
betreuteId={aktive.id}
|
||||
immobilieId={id}
|
||||
darfSchreiben={schreiben}
|
||||
stuecke={stuecke}
|
||||
summeCent={summeCent ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel={immobilie.bezeichnung}
|
||||
beschreibung={anschrift}
|
||||
pfad={[
|
||||
{ titel: 'Übersicht', href: '/heute' },
|
||||
{ titel: 'Immobilie', href: '/immobilie' },
|
||||
{ titel: immobilie.bezeichnung },
|
||||
]}
|
||||
aktionen={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/immobilie/${id}/checkliste`}>
|
||||
<ClipboardList aria-hidden="true" />
|
||||
Pflichten bei Leerstand
|
||||
{ueberfaellig.length > 0 && ` (${ueberfaellig.length} überfällig)`}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/immobilie/${id}/inventar`}>
|
||||
<Package aria-hidden="true" />
|
||||
Inventar
|
||||
</Link>
|
||||
</Button>
|
||||
{schreiben && (
|
||||
<Button asChild>
|
||||
<Link href={`/immobilie/${id}/bearbeiten`}>
|
||||
<Pencil aria-hidden="true" />
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-5" aria-hidden="true" />
|
||||
Stammdatenblatt
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Die Angaben, nach denen jede Behörde und jeder Notar als Erstes fragt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-x-8 sm:grid-cols-2">
|
||||
<dl className="divide-y">
|
||||
<Datenzeile beschriftung="Grundbuchamt">{immobilie.grundbuchamt ?? '–'}</Datenzeile>
|
||||
<Datenzeile beschriftung="Grundbuchblatt">{immobilie.grundbuchBlatt ?? '–'}</Datenzeile>
|
||||
<Datenzeile beschriftung="Gemarkung">{immobilie.gemarkung ?? '–'}</Datenzeile>
|
||||
<Datenzeile beschriftung="Flurstück">{immobilie.flurstueck ?? '–'}</Datenzeile>
|
||||
<Datenzeile beschriftung="Eigentumsanteil">
|
||||
{immobilie.eigentumsanteil ?? '–'}
|
||||
</Datenzeile>
|
||||
</dl>
|
||||
<dl className="divide-y">
|
||||
<Datenzeile beschriftung="Wohnfläche">
|
||||
{immobilie.wohnflaecheQm ? `${immobilie.wohnflaecheQm} m²` : '–'}
|
||||
</Datenzeile>
|
||||
<Datenzeile beschriftung="Grundstücksfläche">
|
||||
{immobilie.grundstueckQm ? `${immobilie.grundstueckQm} m²` : '–'}
|
||||
</Datenzeile>
|
||||
<Datenzeile beschriftung="Baujahr">{immobilie.baujahr ?? '–'}</Datenzeile>
|
||||
<Datenzeile beschriftung="Nutzung">
|
||||
{immobilie.bewohntStatus ? (
|
||||
<Badge variant={immobilie.bewohntStatus === 'leerstehend' ? 'orange' : 'outline'}>
|
||||
{immobilie.bewohntStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
'–'
|
||||
)}
|
||||
</Datenzeile>
|
||||
<Datenzeile beschriftung="Leer stehend seit">
|
||||
{immobilie.leerstandSeit ? formatDatum(immobilie.leerstandSeit) : '–'}
|
||||
</Datenzeile>
|
||||
</dl>
|
||||
|
||||
{offen.notizen && (
|
||||
<div className="sm:col-span-2 mt-4 rounded-md border bg-muted/40 p-3">
|
||||
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Belastungen
|
||||
betreuteId={aktive.id}
|
||||
immobilieId={id}
|
||||
darfSchreiben={schreiben}
|
||||
belastungen={immobilie.belastungen.map((b) => ({
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<WertHistorie
|
||||
betreuteId={aktive.id}
|
||||
immobilieId={id}
|
||||
darfSchreiben={schreiben}
|
||||
werte={wertstaende.map((w) => ({
|
||||
id: w.id,
|
||||
stichtag: w.stichtag.toISOString(),
|
||||
wertCent: centZuZahl(w.wertCent) ?? 0,
|
||||
wertquelle: w.wertquelle,
|
||||
bemerkung: w.bemerkung,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<StatusSozialhilfe
|
||||
betreuteId={aktive.id}
|
||||
immobilieId={id}
|
||||
darfSchreiben={schreiben}
|
||||
status={immobilie.statusSozialhilfe}
|
||||
begruendung={offen.statusBegruendung ?? null}
|
||||
/>
|
||||
|
||||
<Kostenkonto
|
||||
laufendesJahr={laufendesJahr}
|
||||
mehrereObjekte={anzahlObjekte > 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,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Objekt anlegen"
|
||||
beschreibung="Nimm den Grundbuchauszug zur Hand. Was du jetzt nicht weißt, lässt sich jederzeit nachtragen — nur die Bezeichnung ist Pflicht."
|
||||
pfad={[
|
||||
{ titel: 'Übersicht', href: '/heute' },
|
||||
{ titel: 'Immobilie', href: '/immobilie' },
|
||||
{ titel: 'Anlegen' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ImmobilieFormular betreuteId={aktive.id} werte={LEERE_IMMOBILIE} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Immobilie"
|
||||
beschreibung="Hausgrundstücke mit Grundbuch, Belastungen, Wert-Historie und den Pflichten, die bei Leerstand weiterlaufen."
|
||||
pfad={[{ titel: 'Übersicht', href: '/heute' }, { titel: 'Immobilie' }]}
|
||||
aktionen={
|
||||
schreiben && (
|
||||
<Button asChild>
|
||||
<Link href="/immobilie/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Objekt anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{immobilien.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch kein Objekt erfasst"
|
||||
beschreibung="Leg das Hausgrundstück an. Du brauchst dafür den Grundbuchauszug — steht er nicht in den Unterlagen, bekommst du ihn als Betreuer beim Grundbuchamt gegen Vorlage der Bestellungsurkunde."
|
||||
icon={Building2}
|
||||
aktion={
|
||||
schreiben && (
|
||||
<Button asChild>
|
||||
<Link href="/immobilie/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Objekt anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{immobilien.map((immobilie) => {
|
||||
const letzterWert = immobilie.vermoegensposten[0]?.wertstaende[0] ?? null
|
||||
const belastungAbtIII = immobilie.belastungen
|
||||
.filter((b) => b.abteilung === 'III' && !b.geloeschtImGrundbuchAm)
|
||||
.reduce((summe, b) => summe + (b.betragCent ?? 0n), 0n)
|
||||
|
||||
const offenePunkte = immobilie.checklistenPunkte.filter((p) => !p.erledigt)
|
||||
const ueberfaellig = offenePunkte.filter(
|
||||
(p) => p.faelligAm && tageZwischen(heute, p.faelligAm) < 0,
|
||||
)
|
||||
|
||||
return (
|
||||
<Card key={immobilie.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Link href={`/immobilie/${immobilie.id}`} className="hover:underline">
|
||||
{immobilie.bezeichnung}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{[immobilie.strasse, [immobilie.plz, immobilie.ort].filter(Boolean).join(' ')]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Anschrift nicht erfasst'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{immobilie.bewohntStatus && (
|
||||
<Badge variant={immobilie.bewohntStatus === 'leerstehend' ? 'orange' : 'outline'}>
|
||||
{immobilie.bewohntStatus}
|
||||
{immobilie.leerstandSeit && ` seit ${formatDatum(immobilie.leerstandSeit)}`}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline">
|
||||
{IMMOBILIEN_STATUS_BESCHRIFTUNG[immobilie.statusSozialhilfe]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-1 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Letzter Wert</dt>
|
||||
<dd className="font-medium tabular-nums">
|
||||
{letzterWert
|
||||
? `${formatCent(letzterWert.wertCent)} (${formatDatum(letzterWert.stichtag)})`
|
||||
: 'kein Wert hinterlegt'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Grundpfandrechte (Abt. III)</dt>
|
||||
<dd className="font-medium tabular-nums">
|
||||
{belastungAbtIII > 0n ? formatCent(belastungAbtIII) : '–'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{ueberfaellig.length > 0 && (
|
||||
<p className="flex items-start gap-2 text-sm font-medium text-ampel-rot">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
{ueberfaellig.length === 1
|
||||
? '1 Pflicht bei Leerstand ist überfällig.'
|
||||
: `${ueberfaellig.length} Pflichten bei Leerstand sind überfällig.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/immobilie/${immobilie.id}`}>Stammdatenblatt</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/immobilie/${immobilie.id}/checkliste`}>
|
||||
Pflichten bei Leerstand
|
||||
{offenePunkte.length > 0 && ` (${offenePunkte.length})`}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/immobilie/${immobilie.id}/inventar`}>Inventar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage={IMMOBILIEN_STATUS_RECHTSGRUNDLAGE} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten und zweiter Faktor." />
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Zugang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="divide-y">
|
||||
<div className="flex justify-between gap-4 py-2.5">
|
||||
<dt className="text-sm text-muted-foreground">Name</dt>
|
||||
<dd className="font-medium">{benutzer.name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-2.5">
|
||||
<dt className="text-sm text-muted-foreground">E-Mail</dt>
|
||||
<dd className="font-medium">{benutzer.email}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-2.5">
|
||||
<dt className="text-sm text-muted-foreground">Rolle</dt>
|
||||
<dd className="text-right">
|
||||
<Badge variant="secondary">{ROLLEN_BESCHRIFTUNG[benutzer.rolle]}</Badge>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{ROLLEN_ERKLAERUNG[benutzer.rolle]}
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-2.5">
|
||||
<dt className="text-sm text-muted-foreground">Letzte Anmeldung</dt>
|
||||
<dd className="font-medium">{formatDatumZeit(benutzer.letzteAnmeldung)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="size-5" aria-hidden="true" />
|
||||
Zweiter Faktor
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{benutzer.totpAktiviertAm
|
||||
? `Eingerichtet am ${formatDatumZeit(benutzer.totpAktiviertAm)}.`
|
||||
: 'Noch nicht eingerichtet.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm">
|
||||
{codesUebrig > 0 ? (
|
||||
<>
|
||||
Noch <strong>{codesUebrig}</strong> Wiederherstellungscode
|
||||
{codesUebrig === 1 ? '' : 's'} übrig.
|
||||
{codesUebrig <= 2 && (
|
||||
<span className="ml-1 text-ampel-orange">
|
||||
Es wird Zeit, neue zu erzeugen.
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-ampel-rot">
|
||||
Keine Wiederherstellungscodes mehr übrig. Geht dein Handy verloren, kommst du
|
||||
nicht mehr an die Daten. Bitte gleich neue erzeugen.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/konto/zwei-faktor">Zweiten Faktor verwalten</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<KeyRound className="size-5" aria-hidden="true" />
|
||||
Passwort ändern
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Nach dem Ändern werden alle anderen offenen Sitzungen beendet — auch auf Geräten, an
|
||||
denen du gerade nicht sitzt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasswortFormular />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
|
||||
{pending ? 'Wird geändert …' : 'Passwort ändern'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function PasswortFormular() {
|
||||
const [zustand, aktion] = useActionState<PasswortZustand, FormData>(passwortAendern, {})
|
||||
|
||||
if (zustand.erfolg) {
|
||||
return (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
Passwort geändert. Alle offenen Sitzungen wurden beendet — auch diese hier. Beim
|
||||
nächsten Klick wirst du zur Anmeldung geschickt.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Feld id="aktuell" beschriftung="Bisheriges Passwort" fehler={zustand.felder?.aktuell} pflicht>
|
||||
<Input name="aktuell" type="password" autoComplete="current-password" required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="neu"
|
||||
beschriftung="Neues Passwort"
|
||||
erklaerung="Mindestens 12 Zeichen."
|
||||
fehler={zustand.felder?.neu}
|
||||
pflicht
|
||||
>
|
||||
<Input name="neu" type="password" autoComplete="new-password" required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="wiederholung"
|
||||
beschriftung="Neues Passwort wiederholen"
|
||||
fehler={zustand.felder?.wiederholung}
|
||||
pflicht
|
||||
>
|
||||
<Input name="wiederholung" type="password" autoComplete="new-password" required />
|
||||
</Feld>
|
||||
|
||||
<Absenden />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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<string[] | null>(null)
|
||||
const [kopiert, setKopiert] = useState(false)
|
||||
const [laeuft, starten] = useTransition()
|
||||
|
||||
if (codes) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ul className="grid grid-cols-2 gap-2 rounded-md border bg-muted/50 p-4 font-mono text-sm">
|
||||
{codes.map((code) => (
|
||||
<li key={code} className="tracking-wide">
|
||||
{code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(codes.join('\n')).then(() => {
|
||||
setKopiert(true)
|
||||
setTimeout(() => setKopiert(false), 2500)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
|
||||
{kopiert ? 'Kopiert' : 'Codes kopieren'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => window.print()}>
|
||||
Drucken
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={laeuft}
|
||||
onClick={() => {
|
||||
starten(async () => {
|
||||
setCodes(await wiederherstellungscodesNeu())
|
||||
})
|
||||
}}
|
||||
>
|
||||
{laeuft ? <Loader2 className="animate-spin" aria-hidden="true" /> : <RefreshCw aria-hidden="true" />}
|
||||
{laeuft ? 'Wird erzeugt …' : 'Neue Codes erzeugen'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<SeitenKopf
|
||||
titel="Zweiter Faktor"
|
||||
beschreibung="Der Code aus deiner Authenticator-App und die Wiederherstellungscodes."
|
||||
pfad={[{ titel: 'Mein Konto', href: '/konto' }, { titel: 'Zweiter Faktor' }]}
|
||||
/>
|
||||
|
||||
<Alert variant="erfolg">
|
||||
<ShieldCheck aria-hidden="true" />
|
||||
<AlertTitle>Aktiv seit {formatDatumZeit(benutzer.totpAktiviertAm)}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Jede Anmeldung verlangt zusätzlich den sechsstelligen Code. Abschalten lässt sich das
|
||||
nicht — hier liegen Gesundheits- und Vermögensdaten einer Person, die nicht selbst
|
||||
entscheiden kann.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<KeyRound className="size-5" aria-hidden="true" />
|
||||
Wiederherstellungscodes
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Der einzige Weg zurück, wenn das Handy mit der App verloren geht. Jeder Code
|
||||
funktioniert genau einmal.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm">
|
||||
Noch <strong className="tabular-nums">{uebrig}</strong> von ursprünglich 8 Codes
|
||||
übrig.
|
||||
{uebrig <= 2 && uebrig > 0 && (
|
||||
<span className="ml-1 font-medium text-ampel-orange">
|
||||
Erzeuge besser neue, bevor der letzte aufgebraucht ist.
|
||||
</span>
|
||||
)}
|
||||
{uebrig === 0 && (
|
||||
<span className="ml-1 font-medium text-ampel-rot">
|
||||
Ohne Codes bist du bei Verlust des Handys ausgesperrt.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Alert variant="warnung">
|
||||
<AlertDescription>
|
||||
Neue Codes machen die alten sofort ungültig. Für diesen Schritt brauchst du Passwort
|
||||
und Code noch einmal.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<NeueCodes />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel="Antragsakte bearbeiten"
|
||||
beschreibung="Wechselt die Sachbearbeitung oder kommt ein neuer Bewilligungszeitraum, ändere es hier — die Nachforderungen und Bescheide bleiben an der Akte."
|
||||
pfad={[
|
||||
{ titel: 'Start', href: '/heute' },
|
||||
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
||||
{ titel: 'Antragsakte', href: `/sozialhilfe/${antrag.id}` },
|
||||
{ titel: 'Bearbeiten' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<AntragFormular
|
||||
werte={{
|
||||
id: antrag.id,
|
||||
traeger: antrag.traeger,
|
||||
leistungsart: antrag.leistungsart,
|
||||
aktenzeichen: antrag.aktenzeichen ?? '',
|
||||
sachbearbeiter: antrag.sachbearbeiter ?? '',
|
||||
telefon: antrag.telefon ?? '',
|
||||
email: antrag.email ?? '',
|
||||
antragAm: antrag.antragAm ? alsIsoTag(antrag.antragAm) : '',
|
||||
eingangBeimTraegerAm: antrag.eingangBeimTraegerAm
|
||||
? alsIsoTag(antrag.eingangBeimTraegerAm)
|
||||
: '',
|
||||
bewilligtVon: antrag.bewilligtVon ? alsIsoTag(antrag.bewilligtVon) : '',
|
||||
bewilligtBis: antrag.bewilligtBis ? alsIsoTag(antrag.bewilligtBis) : '',
|
||||
notizen: offen.notizen ?? '',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
|
||||
beschreibung={
|
||||
<>
|
||||
{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' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<BescheidPruefung
|
||||
bescheidId={bescheid.id}
|
||||
pruefStatus={bescheid.pruefStatus}
|
||||
widerspruchEingelegtAm={
|
||||
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
|
||||
}
|
||||
darfSchreiben={darfSchreiben}
|
||||
rechenweg={{
|
||||
berechenbar: ergebnis.berechenbar,
|
||||
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
|
||||
? alsIsoTag(ergebnis.widerspruchMoeglichBis)
|
||||
: null,
|
||||
rechnerischesEnde: ergebnis.rechnerischesEnde
|
||||
? alsIsoTag(ergebnis.rechnerischesEnde)
|
||||
: null,
|
||||
verschoben: ergebnis.verschoben,
|
||||
tageVerbleibend: ergebnis.tageVerbleibend,
|
||||
abgelaufen: ergebnis.abgelaufen,
|
||||
ampel: fristAmpel(ergebnis),
|
||||
schritte: ergebnis.schritte,
|
||||
hinweise: ergebnis.hinweise,
|
||||
}}
|
||||
/>
|
||||
|
||||
{offen.notizen && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notizen</CardTitle>
|
||||
<CardDescription>Verschlüsselt gespeichert.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-line text-sm">{offen.notizen}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{darfSchreiben && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
|
||||
<BescheidFormular
|
||||
antragId={antragId}
|
||||
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
|
||||
werte={{
|
||||
id: bescheid.id,
|
||||
art: bescheid.art,
|
||||
bescheidVom: alsIsoTag(bescheid.bescheidVom),
|
||||
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
|
||||
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
|
||||
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
|
||||
betrag: centZuEingabe(bescheid.betragCent),
|
||||
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
|
||||
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
|
||||
? alsIsoTag(bescheid.widerspruchEingelegtAm)
|
||||
: '',
|
||||
notizen: offen.notizen ?? '',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<AktionMitRueckfrage
|
||||
aktion={bescheidLoeschen.bind(null, bescheid.id)}
|
||||
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
Bescheid löschen
|
||||
</AktionMitRueckfrage>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel="Bescheid erfassen"
|
||||
beschreibung={`Post vom ${antrag.traeger}. Wichtig ist der Tag, an dem der Brief im Briefkasten lag — ab da läuft die Widerspruchsfrist.`}
|
||||
pfad={[
|
||||
{ titel: 'Start', href: '/heute' },
|
||||
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
||||
{ titel: 'Antragsakte', href: `/sozialhilfe/${antrag.id}` },
|
||||
{ titel: 'Neuer Bescheid' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<BescheidFormular antragId={antrag.id} werte={LEERER_BESCHEID} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel={antrag.leistungsart}
|
||||
beschreibung={
|
||||
<>
|
||||
{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 && (
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/sozialhilfe/${antrag.id}/bearbeiten`}>
|
||||
<Pencil aria-hidden="true" />
|
||||
Akte bearbeiten
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/sozialhilfe/${antrag.id}/bescheide/neu`}>
|
||||
<Plus aria-hidden="true" />
|
||||
Bescheid
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verfahrensstand</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid gap-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Antrag gestellt</dt>
|
||||
<dd className="font-medium">{formatDatum(antrag.antragAm)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Eingang beim Träger</dt>
|
||||
<dd className="font-medium">{formatDatum(antrag.eingangBeimTraegerAm)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Bewilligt von</dt>
|
||||
<dd className="font-medium">{formatDatum(antrag.bewilligtVon)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Bewilligt bis</dt>
|
||||
<dd className="font-medium">{formatDatum(antrag.bewilligtBis)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{antrag.email && (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">E-Mail: </span>
|
||||
<a href={`mailto:${antrag.email}`} className="underline underline-offset-4">
|
||||
{antrag.email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{offen.notizen && (
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Notizen
|
||||
</p>
|
||||
<p className="mt-1 whitespace-pre-line text-sm">{offen.notizen}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leistungen setzen ein, sobald der Träger von der Notlage Kenntnis hat — nicht erst mit
|
||||
der Bewilligung. Deshalb ist das Eingangsdatum so wichtig.
|
||||
</p>
|
||||
<Rechtshinweis rechtsgrundlage="§ 18 SGB XII" kurz />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<NachforderungTracker
|
||||
antragId={antrag.id}
|
||||
darfSchreiben={darfSchreiben}
|
||||
eintraege={antrag.nachforderungen.map((n) => ({
|
||||
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 && (
|
||||
<ChecklistePanel
|
||||
antragId={antrag.id}
|
||||
offen={[...offeneChecklistenpositionen]}
|
||||
bereitsUebernommen={antrag.nachforderungen.filter((n) => n.ausCheckliste).length}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Bescheide</CardTitle>
|
||||
<CardDescription>
|
||||
Maßgeblich für den Fristbeginn ist das Zugangsdatum, nicht das Datum auf dem
|
||||
Bescheid.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{darfSchreiben && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/sozialhilfe/${antrag.id}/bescheide/neu`}>
|
||||
<Plus aria-hidden="true" />
|
||||
Bescheid erfassen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{antrag.bescheide.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch kein Bescheid erfasst"
|
||||
beschreibung="Sobald Post vom Träger kommt: Datum des Bescheids und Tag des Zugangs eintragen. Die Widerspruchsfrist rechnet die Anwendung dann aus."
|
||||
icon={FileStack}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{antrag.bescheide.map((bescheid) => {
|
||||
const frist = rechtsbehelfsfristBerechnen({
|
||||
zugegangenAm: bescheid.zugegangenAm,
|
||||
bescheidVom: bescheid.bescheidVom,
|
||||
fristMonate: bescheid.rechtsbehelfsfristMonate,
|
||||
heute,
|
||||
})
|
||||
const ampel = fristAmpel(frist)
|
||||
|
||||
return (
|
||||
<li key={bescheid.id} className="p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Link
|
||||
href={`/sozialhilfe/${antrag.id}/bescheide/${bescheid.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{bescheid.art} vom {formatDatum(bescheid.bescheidVom)}
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zugang {formatDatum(bescheid.zugegangenAm)}
|
||||
{bescheid.betragCent !== null && ` · ${formatCent(bescheid.betragCent)}`}
|
||||
{bescheid.zeitraumVon &&
|
||||
` · Zeitraum ${formatDatum(bescheid.zeitraumVon)} – ${formatDatum(bescheid.zeitraumBis)}`}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{naechsterSchritt(bescheid.pruefStatus, frist.tageVerbleibend)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<BescheidAbzeichen status={bescheid.pruefStatus} />
|
||||
{bescheid.pruefStatus === 'UNGEPRUEFT' && (
|
||||
<Badge variant={ampel.variante}>{ampel.text}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<Rechtshinweis className="mt-4" rechtsgrundlage="§ 84 SGG" kurz />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{darfSchreiben && (
|
||||
<div className="border-t pt-6">
|
||||
<AktionMitRueckfrage
|
||||
aktion={antragLoeschen.bind(null, antrag.id)}
|
||||
frage="Diese Antragsakte samt Nachforderungen und Bescheiden in den Papierkorb legen? Sie bleibt dort 30 Tage."
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
Antragsakte löschen
|
||||
</AktionMitRueckfrage>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel={`Heimkosten ${jahr}`}
|
||||
beschreibung="Kosten gegen Deckung, Monat für Monat. Zum Bedarf zählen auch Barbetrag und Bekleidungspauschale — sie müssen der betreuten Person verbleiben."
|
||||
pfad={[
|
||||
{ titel: 'Start', href: '/heute' },
|
||||
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
||||
{ titel: 'Heimkosten' },
|
||||
]}
|
||||
aktionen={
|
||||
<nav aria-label="Jahr wählen" className="flex flex-wrap gap-1">
|
||||
{jahre.map((j) => (
|
||||
<Link
|
||||
key={j}
|
||||
href={`/sozialhilfe/heimkosten?jahr=${j}`}
|
||||
aria-current={j === jahr ? 'page' : undefined}
|
||||
className={`inline-flex h-11 min-w-11 items-center justify-center rounded-md px-3 text-sm font-medium ${
|
||||
j === jahr
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border border-input hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{j}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<p className="text-sm text-muted-foreground">Bedarf {jahr}</p>
|
||||
<p className="text-xl font-semibold tabular-nums">
|
||||
{formatCent(verlauf.summeBedarfCent)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<p className="text-sm text-muted-foreground">Deckung {jahr}</p>
|
||||
<p className="text-xl font-semibold tabular-nums">
|
||||
{formatCent(verlauf.summeDeckungCent)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={verlauf.summeDeckungslueckeCent > 0n ? 'border-ampel-rot/40' : undefined}>
|
||||
<CardContent className="pt-5">
|
||||
<p className="text-sm text-muted-foreground">Deckungslücke {jahr}</p>
|
||||
<p
|
||||
className={`text-xl font-semibold tabular-nums ${
|
||||
verlauf.summeDeckungslueckeCent > 0n ? 'text-ampel-rot' : 'text-ampel-gruen'
|
||||
}`}
|
||||
>
|
||||
{formatCent(verlauf.summeDeckungslueckeCent)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{verlauf.groessteLuecke && (
|
||||
<Alert variant="warnung">
|
||||
<TrendingDown aria-hidden="true" />
|
||||
<AlertTitle>
|
||||
Größte Lücke im {monatsname(verlauf.groessteLuecke.monat)}:{' '}
|
||||
{formatCent(verlauf.groessteLuecke.deckungslueckeCent)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Prüfe, ob der Bescheid für diesen Monat den vollen Bedarf abdeckt. Weicht die Rechnung
|
||||
vom Bescheid ab, ist das der Anlass für eine Rückfrage beim Träger — und gegebenenfalls
|
||||
für einen Widerspruch.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Jahresverlauf</CardTitle>
|
||||
<CardDescription>
|
||||
Gesamtbedarf ist die Heimrechnung plus Barbetrag und Bekleidungspauschale.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<HeimkostenTabelle zeilen={zeilen} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{ergebnisse.some((e) => e.hinweise.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Was auffällt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{ergebnisse.flatMap((e) =>
|
||||
e.hinweise.map((hinweis, i) => (
|
||||
<li key={`${e.monat}-${i}`} className="text-sm">
|
||||
<span className="font-medium">{monatsname(e.monat)}: </span>
|
||||
{hinweis}
|
||||
</li>
|
||||
)),
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{ergebnisse.some((e) => e.abwesenheit.abwesenheitstage > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Abwesenheitstage</CardTitle>
|
||||
<CardDescription>
|
||||
Was die Abwesenheit voraussichtlich bringt — eine Schätzung nach dem Rahmenvertrag,
|
||||
nicht die Abrechnung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{ergebnisse
|
||||
.filter((e) => e.abwesenheit.abwesenheitstage > 0)
|
||||
.map((e) => (
|
||||
<li key={e.monat} className="text-sm">
|
||||
<span className="font-medium">{monatsname(e.monat)}: </span>
|
||||
{e.abwesenheit.erklaerung} Geschätzte Minderung{' '}
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatCent(e.abwesenheit.geschaetzteMinderungCent)}
|
||||
</span>
|
||||
, die Lücke sänke damit auf{' '}
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatCent(e.deckungslueckeNachAbwesenheitCent)}
|
||||
</span>
|
||||
.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Rechtshinweis className="mt-4" rechtsgrundlage="§ 87a Abs. 1 SGB XI, § 75 SGB XI" kurz />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Alert variant="hinweis">
|
||||
<Info aria-hidden="true" />
|
||||
<AlertTitle>Woher die Vorschlagswerte kommen</AlertTitle>
|
||||
<AlertDescription className="space-y-1">
|
||||
<p>
|
||||
Die hinterlegten Beträge haben den Stand {KONSTANTEN_STAND} und sind bis spätestens{' '}
|
||||
{KONSTANTEN_NAECHSTE_PRUEFUNG} nachzuprüfen. Maßgeblich ist immer die Heimrechnung und
|
||||
der Bescheid des Trägers.
|
||||
</p>
|
||||
{verweildauer !== null && (
|
||||
<p>
|
||||
Verweildauer im Heim: {verweildauer}{' '}
|
||||
{verweildauer === 1 ? 'vollendeter Monat' : 'vollendete Monate'} — Leistungszuschlag{' '}
|
||||
{leistungszuschlagProzent(verweildauer)} % ({leistungszuschlagStufe(verweildauer)},
|
||||
§ 43c SGB XI).
|
||||
</p>
|
||||
)}
|
||||
<p>{BEKLEIDUNGSPAUSCHALE_HINWEIS}</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Monat erfassen oder ändern</h2>
|
||||
<HeimkostenFormular
|
||||
jahre={jahre}
|
||||
vorschlagPflegekasse={vorschlagPflegekasse}
|
||||
vorschlagBarbetrag={{
|
||||
betrag: centZuEingabe(BARBETRAG_CENT),
|
||||
text: `Mindestens ${formatCent(BARBETRAG_CENT)} (27 % der Regelbedarfsstufe 1, § 27b Abs. 2 SGB XII, Stand ${KONSTANTEN_STAND}). Steht im Bescheid mehr, gilt der Bescheid.`,
|
||||
}}
|
||||
werte={{
|
||||
jahr,
|
||||
monat: naechsterOffenerMonat,
|
||||
pflegeEigenanteil: centZuEingabe(heimvertrag?.pflegeEigenanteilCent ?? null),
|
||||
unterkunftVerpflegung: centZuEingabe(heimvertrag?.unterkunftVerpflegungCent ?? null),
|
||||
investitionskosten: centZuEingabe(heimvertrag?.investitionskostenCent ?? null),
|
||||
ausbildungsumlage: centZuEingabe(heimvertrag?.ausbildungsumlageCent ?? null),
|
||||
zusatzleistungen: '',
|
||||
pflegekasse: pauschale ? centZuEingabe(pauschale) : '',
|
||||
leistungszuschlagProzent:
|
||||
verweildauer !== null ? leistungszuschlagProzent(verweildauer) : 0,
|
||||
leistungszuschlag: '',
|
||||
rente: '',
|
||||
sonstigeEinkuenfte: '',
|
||||
sozialhilfe: '',
|
||||
eigenmittel: '',
|
||||
barbetrag: centZuEingabe(BARBETRAG_CENT),
|
||||
bekleidungspauschale: '',
|
||||
abwesenheitstage: 0,
|
||||
bemerkung: '',
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel="Weitere Leistungen"
|
||||
beschreibung="Was neben der Hilfe zur Pflege noch zusteht — mit Träger, Rechtsgrundlage und dem, was man für den Antrag braucht."
|
||||
pfad={[
|
||||
{ titel: 'Start', href: '/heute' },
|
||||
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
||||
{ titel: 'Weitere Leistungen' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{zeilen.length === 0 && offeneKatalogeintraege.length === LEISTUNGSKATALOG.length && !darfSchreiben ? (
|
||||
<Leerzustand
|
||||
titel="Noch keine Leistungen erfasst"
|
||||
beschreibung="Zum Übernehmen aus dem Katalog fehlt dir die Schreibberechtigung."
|
||||
icon={Sparkles}
|
||||
/>
|
||||
) : (
|
||||
<LeistungenListe
|
||||
leistungen={zeilen}
|
||||
offeneKatalogeintraege={[...offeneKatalogeintraege]}
|
||||
darfSchreiben={darfSchreiben}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 62 SGB V, § 152 SGB IX, § 4 RBStV, BayLPflGG" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel="Neue Antragsakte"
|
||||
beschreibung="Trag ein, was auf dem Antrag und den Schreiben des Trägers steht. Alles Weitere — Nachforderungen, Bescheide — hängt danach an dieser Akte."
|
||||
pfad={[
|
||||
{ titel: 'Start', href: '/heute' },
|
||||
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
||||
{ titel: 'Neue Akte' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<AntragFormular
|
||||
werte={{
|
||||
id: null,
|
||||
traeger: 'Bezirk Oberfranken',
|
||||
leistungsart: 'Hilfe zur Pflege nach dem 7. Kapitel SGB XII',
|
||||
aktenzeichen: '',
|
||||
sachbearbeiter: '',
|
||||
telefon: '',
|
||||
email: '',
|
||||
antragAm: '',
|
||||
eingangBeimTraegerAm: '',
|
||||
bewilligtVon: '',
|
||||
bewilligtBis: '',
|
||||
notizen: '',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel="Sozialhilfe"
|
||||
beschreibung={
|
||||
<>
|
||||
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 && (
|
||||
<Button asChild>
|
||||
<Link href="/sozialhilfe/neu">
|
||||
<Plus aria-hidden="true" />
|
||||
Antragsakte
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{offeneBescheide.length > 0 && (
|
||||
<Card className="border-ampel-rot/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Ungeprüfte Bescheide</CardTitle>
|
||||
<CardDescription>
|
||||
Solange hier etwas steht, läuft eine Widerspruchsfrist ungenutzt weiter.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{offeneBescheide.map(({ antrag, bescheid, frist }) => {
|
||||
const ampel = fristAmpel(frist)
|
||||
return (
|
||||
<li key={bescheid.id} className="flex flex-wrap items-center gap-3 p-3">
|
||||
<Link
|
||||
href={`/sozialhilfe/${antrag.id}/bescheide/${bescheid.id}`}
|
||||
className="min-w-0 flex-1 font-medium hover:underline"
|
||||
>
|
||||
{bescheid.art} vom {formatDatum(bescheid.bescheidVom)}
|
||||
<span className="block text-sm font-normal text-muted-foreground">
|
||||
{antrag.traeger}
|
||||
{antrag.aktenzeichen ? ` · Az. ${antrag.aktenzeichen}` : ''}
|
||||
{frist.widerspruchMoeglichBis
|
||||
? ` · Widerspruch möglich bis ${formatDatum(frist.widerspruchMoeglichBis)}`
|
||||
: ' · Zugangsdatum fehlt'}
|
||||
</span>
|
||||
</Link>
|
||||
<Badge variant={ampel.variante}>{ampel.text}</Badge>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<Rechtshinweis className="mt-4" rechtsgrundlage="§ 84 SGG" kurz />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Bereichskachel
|
||||
href="/sozialhilfe/vermoegen"
|
||||
titel="Vermögen bewerten"
|
||||
text="Verwertbar, geschützt oder strittig — mit Begründung für den Träger."
|
||||
icon={Coins}
|
||||
/>
|
||||
<Bereichskachel
|
||||
href="/sozialhilfe/heimkosten"
|
||||
titel="Heimkosten"
|
||||
text="Kosten gegen Deckung, Monat für Monat. Wo bleibt die Lücke?"
|
||||
icon={Receipt}
|
||||
/>
|
||||
<Bereichskachel
|
||||
href="/sozialhilfe/leistungen"
|
||||
titel="Weitere Leistungen"
|
||||
text="Landespflegegeld, Zuzahlungsbefreiung, Rundfunkbeitrag und mehr."
|
||||
icon={Sparkles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Antragsakten</h2>
|
||||
|
||||
{antraege.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch keine Antragsakte"
|
||||
beschreibung="Leg die Akte zum Antrag beim Bezirk an. Danach kannst du die Standardunterlagen aus der Checkliste übernehmen und musst sie nicht abtippen."
|
||||
icon={Landmark}
|
||||
aktion={
|
||||
darfSchreiben && (
|
||||
<Button asChild>
|
||||
<Link href="/sozialhilfe/neu">Antragsakte anlegen</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{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 (
|
||||
<li key={antrag.id}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>
|
||||
<Link href={`/sozialhilfe/${antrag.id}`} className="hover:underline">
|
||||
{antrag.leistungsart}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{antrag.traeger}
|
||||
{antrag.aktenzeichen ? ` · Az. ${antrag.aktenzeichen}` : ''}
|
||||
{antrag.sachbearbeiter ? ` · ${antrag.sachbearbeiter}` : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{letzterBescheid && <BescheidAbzeichen status={letzterBescheid.pruefStatus} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Antrag gestellt</dt>
|
||||
<dd className="font-medium">{formatDatum(antrag.antragAm)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Eingang beim Träger</dt>
|
||||
<dd className="font-medium">{formatDatum(antrag.eingangBeimTraegerAm)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Bewilligt</dt>
|
||||
<dd className="font-medium">
|
||||
{antrag.bewilligtVon || antrag.bewilligtBis
|
||||
? `${formatDatum(antrag.bewilligtVon)} – ${formatDatum(antrag.bewilligtBis)}`
|
||||
: 'noch nicht'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{fortschritt.gesamt > 0 && (
|
||||
<Fortschritt
|
||||
wert={fortschritt.geliefert}
|
||||
gesamt={fortschritt.gesamt}
|
||||
beschriftung={fortschritt.beschriftung}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{fortschritt.ueberfaellig.length > 0 && (
|
||||
<Badge variant="rot">
|
||||
{fortschritt.ueberfaellig.length} überfällig
|
||||
</Badge>
|
||||
)}
|
||||
{fortschritt.dieseWoche.length > 0 && (
|
||||
<Badge variant="orange">
|
||||
{fortschritt.dieseWoche.length} diese Woche fällig
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline">
|
||||
<FileStack className="size-3" aria-hidden="true" />
|
||||
{antrag.bescheide.length}{' '}
|
||||
{antrag.bescheide.length === 1 ? 'Bescheid' : 'Bescheide'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/sozialhilfe/${antrag.id}`}>Akte öffnen</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="7. Kapitel SGB XII" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Bereichskachel({
|
||||
href,
|
||||
titel,
|
||||
text,
|
||||
icon: Icon,
|
||||
}: {
|
||||
href: string
|
||||
titel: string
|
||||
text: string
|
||||
icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Icon className="mb-2 size-6 text-primary" aria-hidden={true} />
|
||||
<span className="block font-semibold">{titel}</span>
|
||||
<span className="mt-1 block text-sm text-muted-foreground">{text}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<SeitenKopf
|
||||
titel="Vermögen bewerten"
|
||||
beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter."
|
||||
pfad={[
|
||||
{ titel: 'Start', href: '/heute' },
|
||||
{ titel: 'Sozialhilfe', href: '/sozialhilfe' },
|
||||
{ titel: 'Vermögen' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Summenkachel
|
||||
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
|
||||
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
|
||||
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
|
||||
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
|
||||
farbe="text-ampel-rot"
|
||||
/>
|
||||
<Summenkachel
|
||||
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
|
||||
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
|
||||
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
|
||||
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
|
||||
farbe="text-ampel-gruen"
|
||||
/>
|
||||
<Summenkachel
|
||||
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
|
||||
betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
|
||||
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
|
||||
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
|
||||
farbe="text-ampel-orange"
|
||||
/>
|
||||
<Summenkachel
|
||||
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
|
||||
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
|
||||
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
|
||||
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
|
||||
farbe="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-2 pt-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
|
||||
</span>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{formatCent(vergleich.freibetragCent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">Über dem Freibetrag</span>
|
||||
<span
|
||||
className={`text-lg font-semibold tabular-nums ${
|
||||
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
|
||||
}`}
|
||||
>
|
||||
{formatCent(vergleich.ueberFreibetragCent)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
|
||||
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{uebersicht.ungeprueft.length > 0 && (
|
||||
<Alert variant="warnung">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>
|
||||
{uebersicht.ungeprueft.length}{' '}
|
||||
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
|
||||
kostet Wochen.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uebersicht.ohneBegruendung.length > 0 && (
|
||||
<Alert variant="warnung">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Bewertung ohne Begründung</AlertTitle>
|
||||
<AlertDescription>
|
||||
{uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf
|
||||
sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Summenkachel({
|
||||
titel,
|
||||
betrag,
|
||||
anzahl,
|
||||
ohneWert,
|
||||
farbe,
|
||||
}: {
|
||||
titel: string
|
||||
betrag: bigint
|
||||
anzahl: number
|
||||
ohneWert: number
|
||||
farbe: string
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<p className="text-sm text-muted-foreground">{titel}</p>
|
||||
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
|
||||
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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<NextResponse> {
|
||||
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'
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<p key={`p-${schluessel}`} className="leading-relaxed">
|
||||
{absatz.join(' ')}
|
||||
</p>,
|
||||
)
|
||||
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(
|
||||
<h2 key={i} className="mt-8 text-xl font-semibold">
|
||||
{roh.slice(3)}
|
||||
</h2>,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (roh.startsWith('# ')) {
|
||||
absatzSchliessen(i)
|
||||
bloecke.push(
|
||||
<h1 key={i} className="text-2xl font-semibold">
|
||||
{roh.slice(2)}
|
||||
</h1>,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (roh.startsWith('- ')) {
|
||||
absatzSchliessen(i)
|
||||
bloecke.push(
|
||||
<li key={i} className="ml-5 list-disc leading-relaxed">
|
||||
{roh.slice(2)}
|
||||
</li>,
|
||||
)
|
||||
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(
|
||||
<div key={i} className="flex flex-col gap-0.5 border-l-2 py-1 pl-3 sm:flex-row sm:gap-3">
|
||||
<span className="min-w-56 font-medium">{spalten[0]}</span>
|
||||
<span className="text-muted-foreground">{spalten.slice(1).join(' · ')}</span>
|
||||
</div>,
|
||||
)
|
||||
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 (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
|
||||
<article className="space-y-3 [&_strong]:font-semibold">{alsAbsaetze(inhalt)}</article>
|
||||
|
||||
<p className="mt-10 border-t pt-6 text-sm text-muted-foreground">
|
||||
<Link href="/anmelden" className="underline hover:no-underline">
|
||||
Zurück zur Anmeldung
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Button type="submit" disabled={pending}>
|
||||
<FolderArchive aria-hidden="true" />
|
||||
{pending ? 'Belege werden zusammengelegt …' : 'Belegmappe erzeugen'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Aktionszustand, FormData>(belegmappeAnlegen, {})
|
||||
const [quelle, setQuelle] = React.useState<'zeitraum' | 'vorgang'>('zeitraum')
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
|
||||
<Meldung fehler={zustand.fehler} erfolg={zustand.erfolg} />
|
||||
|
||||
<Feld
|
||||
id="quelle"
|
||||
beschriftung="Woraus soll die Mappe entstehen?"
|
||||
erklaerung="Ein Zeitraum liefert die Belege zur Rechnungslegung, ein Vorgang die Belege zu einer einzelnen Sache."
|
||||
pflicht
|
||||
>
|
||||
<Select
|
||||
name="quelle"
|
||||
value={quelle}
|
||||
onChange={(ereignis) => setQuelle(ereignis.target.value as 'zeitraum' | 'vorgang')}
|
||||
>
|
||||
<option value="zeitraum">Alle Belege eines Zeitraums</option>
|
||||
<option value="vorgang">Alle Belege zu einem Vorgang</option>
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
{quelle === 'zeitraum' ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="zeitraumVon" beschriftung="Von" fehler={zustand.felder?.zeitraumVon} pflicht>
|
||||
<Input type="date" name="zeitraumVon" defaultValue={vorschlagVon} required />
|
||||
</Feld>
|
||||
<Feld id="zeitraumBis" beschriftung="Bis" fehler={zustand.felder?.zeitraumBis} pflicht>
|
||||
<Input type="date" name="zeitraumBis" defaultValue={vorschlagBis} required />
|
||||
</Feld>
|
||||
</div>
|
||||
) : (
|
||||
<Feld
|
||||
id="vorgangId"
|
||||
beschriftung="Vorgang"
|
||||
erklaerung="Es werden alle Dokumente aufgenommen, die mit diesem Vorgang verknüpft sind."
|
||||
fehler={zustand.felder?.vorgangId}
|
||||
pflicht
|
||||
>
|
||||
<Select name="vorgangId" required defaultValue="">
|
||||
<option value="" disabled>
|
||||
Bitte wählen
|
||||
</option>
|
||||
{vorgaenge.map((vorgang) => (
|
||||
<option key={vorgang.id} value={vorgang.id}>
|
||||
{vorgang.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
<Feld
|
||||
id="titel"
|
||||
beschriftung="Überschrift der Mappe"
|
||||
erklaerung="Steht auf dem Deckblatt. Leer lassen genügt meistens."
|
||||
fehler={zustand.felder?.titel}
|
||||
>
|
||||
<Input name="titel" placeholder="Belegmappe zur Rechnungslegung" maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Absenden />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Button type="submit" variant={variante} disabled={pending}>
|
||||
{pending ? laeuftText : children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Aktionszustand, FormData>(entwurfSpeichern, {})
|
||||
const [ausgabestand, ausgeben] = useActionState<Aktionszustand, FormData>(jahresberichtAusgeben, {})
|
||||
const [neustand, neuBerechnen] = useActionState<Aktionszustand, FormData>(entwurfNeuBerechnen, {})
|
||||
|
||||
const formular = React.useRef<HTMLFormElement>(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 (
|
||||
<div className="space-y-5">
|
||||
<Alert variant="warnung">
|
||||
<AlertTitle>Das ist ein Entwurf, kein fertiger Bericht</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{restlicheStellen > 0 && (
|
||||
<p className="mt-2 font-medium">
|
||||
Im Text stehen noch {restlicheStellen}{' '}
|
||||
{restlicheStellen === 1 ? 'Stelle' : 'Stellen'} mit „{ergaenzenMarke}“, die auf dich
|
||||
warten.
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Meldung fehler={speicherstand.fehler} erfolg={speicherstand.erfolg} />
|
||||
<Meldung fehler={ausgabestand.fehler} erfolg={ausgabestand.erfolg} />
|
||||
<Meldung fehler={neustand.fehler} erfolg={neustand.erfolg} />
|
||||
|
||||
<form action={speichern} ref={formular} className="space-y-4">
|
||||
<input type="hidden" name="berichtId" value={berichtId} />
|
||||
<input type="hidden" name="entwurf" value={text} />
|
||||
|
||||
<Feld
|
||||
id="entwurf"
|
||||
beschriftung="Berichtstext"
|
||||
erklaerung="Überschriften beginnen mit ihrer Nummer („3. Wünsche …“), Aufzählungen mit einem Punkt (•). Daran erkennt die Anwendung sie später beim Setzen von PDF und Word-Datei."
|
||||
fehler={speicherstand.felder?.entwurf}
|
||||
>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(ereignis) => setText(ereignis.target.value)}
|
||||
rows={30}
|
||||
spellCheck
|
||||
className="font-normal leading-relaxed"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Knopf laeuftText="Wird gespeichert …">
|
||||
<Save aria-hidden="true" />
|
||||
Zwischenspeichern
|
||||
</Knopf>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{geaendert
|
||||
? 'Nicht gespeicherte Änderungen – es wird spätestens in einer Minute automatisch gesichert.'
|
||||
: 'Alles gespeichert.'}
|
||||
{offeneStellen > 0 && ' '}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-3 border-t pt-5">
|
||||
<form action={ausgeben}>
|
||||
<input type="hidden" name="berichtId" value={berichtId} />
|
||||
<input type="hidden" name="format" value="pdf" />
|
||||
<Knopf laeuftText="PDF wird gesetzt …">
|
||||
<FileDown aria-hidden="true" />
|
||||
{hatPdf ? 'PDF neu erzeugen' : 'PDF erzeugen'}
|
||||
</Knopf>
|
||||
</form>
|
||||
|
||||
<form action={ausgeben}>
|
||||
<input type="hidden" name="berichtId" value={berichtId} />
|
||||
<input type="hidden" name="format" value="docx" />
|
||||
<Knopf variante="outline" laeuftText="Word-Datei wird gebaut …">
|
||||
<FileType2 aria-hidden="true" />
|
||||
Als Word-Datei
|
||||
</Knopf>
|
||||
</form>
|
||||
|
||||
<form
|
||||
action={neuBerechnen}
|
||||
onSubmit={(ereignis) => {
|
||||
const sicher = window.confirm(
|
||||
'Der Entwurf wird aus den aktuellen Daten neu aufgebaut. Alles, was du selbst ' +
|
||||
'geschrieben hast, geht dabei verloren. Fortfahren?',
|
||||
)
|
||||
if (!sicher) ereignis.preventDefault()
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="berichtId" value={berichtId} />
|
||||
<Knopf variante="secondary" laeuftText="Wird neu aufgebaut …">
|
||||
<RefreshCcw aria-hidden="true" />
|
||||
Entwurf neu aufbauen
|
||||
</Knopf>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Meldung } from '@/components/berichte/meldung'
|
||||
import { jahresberichtAnlegen, type Aktionszustand } from '@/server/actions/berichte'
|
||||
|
||||
function Absenden() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
<FileText aria-hidden="true" />
|
||||
{pending ? 'Entwurf wird gebaut …' : 'Entwurf erzeugen'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet einen Jahresbericht fuer einen Berichtszeitraum.
|
||||
*
|
||||
* Vorbelegt ist das laufende Betreuungsjahr - es beginnt am Tag der
|
||||
* Bestellung, nicht am 1. Januar. Wer einen anderen Zeitraum braucht (etwa
|
||||
* fuer eine Nachforderung des Gerichts), aendert die beiden Felder.
|
||||
*/
|
||||
export function JahresberichtFormular({
|
||||
betreuteId,
|
||||
vorschlagVon,
|
||||
vorschlagBis,
|
||||
}: {
|
||||
betreuteId: string
|
||||
/** ISO-Tag, z.B. "2025-10-01". */
|
||||
vorschlagVon: string
|
||||
vorschlagBis: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(jahresberichtAnlegen, {})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (zustand.berichtId) router.push(`/berichte/jahresbericht/${zustand.berichtId}`)
|
||||
}, [zustand.berichtId, router])
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
|
||||
<Meldung fehler={zustand.fehler} erfolg={zustand.erfolg} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="zeitraumVon"
|
||||
beschriftung="Berichtszeitraum von"
|
||||
erklaerung="In der Regel der Tag, an dem das laufende Betreuungsjahr begonnen hat."
|
||||
fehler={zustand.felder?.zeitraumVon}
|
||||
pflicht
|
||||
>
|
||||
<Input type="date" name="zeitraumVon" defaultValue={vorschlagVon} required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="zeitraumBis"
|
||||
beschriftung="Berichtszeitraum bis"
|
||||
erklaerung="Meist der Tag vor dem nächsten Jahrestag der Bestellung."
|
||||
fehler={zustand.felder?.zeitraumBis}
|
||||
pflicht
|
||||
>
|
||||
<Input type="date" name="zeitraumBis" defaultValue={vorschlagBis} required />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Absenden />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
|
||||
/**
|
||||
* Die Rueckmeldung einer Server Action - Fehler, Erfolg, Warnung.
|
||||
*
|
||||
* Eigene Komponente, weil sie in jedem Berichtsformular gebraucht wird und
|
||||
* weil sie "aria-live" tragen muss: Wer mit Screenreader arbeitet, soll
|
||||
* mitbekommen, dass etwas passiert ist, ohne die Seite abzusuchen.
|
||||
*/
|
||||
export function Meldung({
|
||||
fehler,
|
||||
erfolg,
|
||||
warnung,
|
||||
}: {
|
||||
fehler?: string
|
||||
erfolg?: string
|
||||
warnung?: string
|
||||
}) {
|
||||
if (!fehler && !erfolg && !warnung) return null
|
||||
|
||||
return (
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<AlertDescription>{fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{warnung && (
|
||||
<Alert variant="warnung">
|
||||
<Info aria-hidden="true" />
|
||||
<AlertDescription>{warnung}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{erfolg && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{erfolg}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ArrowDownLeft, ArrowUpRight, CircleDot, FileText, Loader2, ScanLine } from 'lucide-react'
|
||||
import type { DokumentArt, DokumentRichtung, PosteingangStatus } from '@prisma/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import { POSTEINGANG_BESCHRIFTUNG } from '@/lib/validierung/dokumente'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Die kleinen Kennzeichen an einem Dokument.
|
||||
*
|
||||
* Ueberall gilt: Farbe ist nie die einzige Information. Jede Ampel traegt
|
||||
* ihren Text mit, sonst waere die Liste fuer farbfehlsichtige Augen leer.
|
||||
*/
|
||||
|
||||
export function ArtAbzeichen({ art, className }: { art: DokumentArt; className?: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn('gap-1', className)}>
|
||||
<FileText className="size-3" aria-hidden="true" />
|
||||
{ART_BESCHRIFTUNG[art]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function RichtungAbzeichen({ richtung }: { richtung: DokumentRichtung }) {
|
||||
const Icon =
|
||||
richtung === 'EINGANG' ? ArrowDownLeft : richtung === 'AUSGANG' ? ArrowUpRight : CircleDot
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Icon className="size-3" aria-hidden="true" />
|
||||
{RICHTUNG_BESCHRIFTUNG[richtung]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const POSTEINGANG_VARIANTE: Record<
|
||||
PosteingangStatus,
|
||||
'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
|
||||
> = {
|
||||
NEU: 'orange',
|
||||
GESICHTET: 'gelb',
|
||||
ZUGEORDNET: 'secondary',
|
||||
BEANTWORTET: 'gruen',
|
||||
}
|
||||
|
||||
export function PosteingangAbzeichen({ status }: { status: PosteingangStatus }) {
|
||||
return <Badge variant={POSTEINGANG_VARIANTE[status]}>{POSTEINGANG_BESCHRIFTUNG[status]}</Badge>
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Stand der Texterkennung. "nicht verfügbar" ist ausdruecklich kein
|
||||
* Fehler: auf einem Windows-Rechner ohne Tesseract ist das der Normalfall,
|
||||
* und das Dokument liegt trotzdem vollstaendig in der Akte.
|
||||
*/
|
||||
export function OcrAbzeichen({ status }: { status: string | null }) {
|
||||
if (!status || status === 'fertig') return null
|
||||
|
||||
const texte: Record<string, { text: string; variante: 'gelb' | 'secondary' | 'orange' }> = {
|
||||
ausstehend: { text: 'Texterkennung läuft gleich', variante: 'gelb' },
|
||||
laeuft: { text: 'Texterkennung läuft', variante: 'gelb' },
|
||||
fehlgeschlagen: { text: 'Texterkennung fehlgeschlagen', variante: 'orange' },
|
||||
nicht_verfuegbar: { text: 'ohne Volltext', variante: 'secondary' },
|
||||
}
|
||||
|
||||
const eintrag = texte[status]
|
||||
if (!eintrag) return null
|
||||
|
||||
const Icon = status === 'laeuft' || status === 'ausstehend' ? Loader2 : ScanLine
|
||||
|
||||
return (
|
||||
<Badge variant={eintrag.variante} className="gap-1">
|
||||
<Icon className={cn('size-3', status === 'laeuft' && 'animate-spin')} aria-hidden="true" />
|
||||
{eintrag.text}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Frei gewaehlte Schlagworte. */
|
||||
export function TagAbzeichen({ name }: { name: string; farbe?: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, BookOpen, Download, Loader2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { belegmappeAnfordern, type Mappenantwort } from '@/server/actions/dokumente'
|
||||
|
||||
/**
|
||||
* Belegmappe erzeugen: alle Belege eines Vorgangs oder eines Zeitraums als
|
||||
* **eine** chronologisch sortierte PDF mit Deckblatt, Inhaltsverzeichnis
|
||||
* und durchgehender Seitenzahl.
|
||||
*
|
||||
* Genau das erwarten Gericht und Bezirk zur Rechnungslegung - nicht
|
||||
* dreissig einzelne Anhaenge. Auf die Seitenzahl der Mappe kann sich der
|
||||
* Bericht dann berufen ("Beleg 7, Seite 23").
|
||||
*/
|
||||
export function BelegmappeFormular({ vorgaenge }: { vorgaenge: { id: string; titel: string }[] }) {
|
||||
const [zustand, aktion] = useActionState<Mappenantwort, FormData>(belegmappeAnfordern, {})
|
||||
const [art, setArt] = React.useState<'zeitraum' | 'vorgang'>('zeitraum')
|
||||
|
||||
const jahr = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{zustand.url && (
|
||||
<Alert variant="erfolg">
|
||||
<Download aria-hidden="true" />
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>{zustand.hinweis}</p>
|
||||
<a
|
||||
href={zustand.url}
|
||||
className="inline-flex font-medium text-primary underline underline-offset-4"
|
||||
>
|
||||
{zustand.dateiname ?? 'Belegmappe'} herunterladen
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Die Adresse gilt zehn Minuten. Danach hier neu erzeugen.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{zustand.hinweise && zustand.hinweise.length > 0 && (
|
||||
<Alert variant="warnung">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
{zustand.hinweise.map((hinweis, i) => (
|
||||
<li key={i}>{hinweis}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="mappe-art" beschriftung="Was soll in die Mappe?">
|
||||
<Select
|
||||
name="art"
|
||||
value={art}
|
||||
onChange={(ereignis) => setArt(ereignis.target.value as 'zeitraum' | 'vorgang')}
|
||||
>
|
||||
<option value="zeitraum">Alle Belege eines Zeitraums</option>
|
||||
<option value="vorgang">Alle Belege eines Vorgangs</option>
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
{art === 'vorgang' ? (
|
||||
<Feld id="mappe-vorgang" beschriftung="Vorgang">
|
||||
<Select name="vorgangId" disabled={vorgaenge.length === 0}>
|
||||
{vorgaenge.map((vorgang) => (
|
||||
<option key={vorgang.id} value={vorgang.id}>
|
||||
{vorgang.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
) : (
|
||||
<>
|
||||
<Feld id="mappe-von" beschriftung="Von" pflicht>
|
||||
<Input name="von" type="date" defaultValue={`${jahr}-01-01`} required />
|
||||
</Feld>
|
||||
<Feld id="mappe-bis" beschriftung="Bis" pflicht>
|
||||
<Input name="bis" type="date" defaultValue={`${jahr}-12-31`} required />
|
||||
</Feld>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MappeKnopf gesperrt={art === 'vorgang' && vorgaenge.length === 0} />
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" kurz />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function MappeKnopf({ gesperrt }: { gesperrt: boolean }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending || gesperrt}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
Wird zusammengestellt …
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen aria-hidden="true" />
|
||||
Belegmappe erzeugen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AlertCircle, Download, Eye, EyeOff, Loader2, Trash2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { dokumentLoeschen, vorschauUrl } from '@/server/actions/dokumente'
|
||||
|
||||
/**
|
||||
* Datei ansehen, herunterladen, in den Papierkorb legen.
|
||||
*
|
||||
* Die Datei wird nie ueber eine dauerhafte Adresse ausgeliefert. Bei jedem
|
||||
* Klick wird eine frische, fuenf Minuten gueltige Adresse geholt; der
|
||||
* Server prueft beim Abruf zusaetzlich, ob der angemeldete Benutzer diese
|
||||
* betreute Person betreut, und schreibt einen Eintrag ins Audit-Log.
|
||||
* Ein weitergereichter Link oeffnet deshalb nichts.
|
||||
*/
|
||||
export function DateiAktionen({
|
||||
dokumentId,
|
||||
istBild,
|
||||
hatDatei,
|
||||
schreibrecht,
|
||||
}: {
|
||||
dokumentId: string
|
||||
istBild: boolean
|
||||
hatDatei: boolean
|
||||
schreibrecht: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [laeuft, setLaeuft] = React.useState<'oeffnen' | 'vorschau' | 'loeschen' | null>(null)
|
||||
const [fehler, setFehler] = React.useState<string | null>(null)
|
||||
const [vorschau, setVorschau] = React.useState<string | null>(null)
|
||||
|
||||
async function frischeUrl(): Promise<string | null> {
|
||||
const antwort = await vorschauUrl(dokumentId)
|
||||
if (antwort.fehler || !antwort.url) {
|
||||
setFehler(antwort.fehler ?? 'Die Datei ließ sich nicht öffnen.')
|
||||
return null
|
||||
}
|
||||
setFehler(null)
|
||||
return antwort.url
|
||||
}
|
||||
|
||||
if (!hatDatei) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zu diesem Eintrag ist keine Datei hinterlegt – nur die Angaben in der Akte.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={laeuft !== null}
|
||||
onClick={async () => {
|
||||
setLaeuft('oeffnen')
|
||||
const url = await frischeUrl()
|
||||
setLaeuft(null)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
>
|
||||
{laeuft === 'oeffnen' ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Download aria-hidden="true" />
|
||||
)}
|
||||
Datei öffnen
|
||||
</Button>
|
||||
|
||||
{istBild &&
|
||||
(vorschau ? (
|
||||
<Button type="button" variant="outline" onClick={() => setVorschau(null)}>
|
||||
<EyeOff aria-hidden="true" />
|
||||
Vorschau schließen
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={laeuft !== null}
|
||||
onClick={async () => {
|
||||
setLaeuft('vorschau')
|
||||
const url = await frischeUrl()
|
||||
setLaeuft(null)
|
||||
if (url) setVorschau(url)
|
||||
}}
|
||||
>
|
||||
{laeuft === 'vorschau' ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye aria-hidden="true" />
|
||||
)}
|
||||
Vorschau anzeigen
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{schreibrecht && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={laeuft !== null}
|
||||
onClick={async () => {
|
||||
const sicher = window.confirm(
|
||||
'Dieses Dokument in den Papierkorb legen? Du kannst es 30 Tage lang zurückholen.',
|
||||
)
|
||||
if (!sicher) return
|
||||
setLaeuft('loeschen')
|
||||
const ergebnis = await dokumentLoeschen(dokumentId)
|
||||
setLaeuft(null)
|
||||
if (ergebnis.fehler) setFehler(ergebnis.fehler)
|
||||
else router.push('/dokumente')
|
||||
}}
|
||||
>
|
||||
{laeuft === 'loeschen' ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Trash2 aria-hidden="true" />
|
||||
)}
|
||||
In den Papierkorb
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vorschau && (
|
||||
<figure className="overflow-hidden rounded-lg border bg-muted/30">
|
||||
{/* Bewusst ein einfaches <img>: die Datei wird als Anhang mit
|
||||
nosniff ausgeliefert und kann so nichts ausfuehren. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={vorschau}
|
||||
alt="Vorschau des hochgeladenen Dokuments"
|
||||
className="max-h-[70vh] w-full object-contain"
|
||||
onError={() =>
|
||||
setFehler(
|
||||
'Die Vorschau lässt sich in diesem Browser nicht anzeigen. Mit „Datei öffnen“ klappt es.',
|
||||
)
|
||||
}
|
||||
/>
|
||||
<figcaption className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
Vorschau – die Adresse gilt nur wenige Minuten.
|
||||
</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Link from 'next/link'
|
||||
import { Link2, Paperclip } from 'lucide-react'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { formatBytes } from '@/lib/speicher/dateityp'
|
||||
import type { Listeneintrag } from '@/lib/dokumente/suche'
|
||||
import {
|
||||
ArtAbzeichen,
|
||||
OcrAbzeichen,
|
||||
PosteingangAbzeichen,
|
||||
RichtungAbzeichen,
|
||||
TagAbzeichen,
|
||||
} from '@/components/dokumente/abzeichen'
|
||||
|
||||
/**
|
||||
* Die Dokumentenliste.
|
||||
*
|
||||
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
|
||||
* scrollt dabei im eigenen Rahmen, nie die ganze Seite.
|
||||
*/
|
||||
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
|
||||
return (
|
||||
<>
|
||||
{/* Handy */}
|
||||
<ul className="space-y-3 md:hidden">
|
||||
{eintraege.map((eintrag) => (
|
||||
<li key={eintrag.id} className="rounded-lg border bg-card p-4">
|
||||
<Link
|
||||
href={`/dokumente/${eintrag.id}`}
|
||||
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{eintrag.titel}
|
||||
</Link>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{eintrag.absender ?? 'Ohne Absender'} · {formatDatum(eintrag.eingangAm ?? eintrag.schreibenVom)}
|
||||
</p>
|
||||
<Ausschnitt text={eintrag.ausschnitt} />
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<ArtAbzeichen art={eintrag.art} />
|
||||
<RichtungAbzeichen richtung={eintrag.richtung} />
|
||||
<PosteingangAbzeichen status={eintrag.posteingangStatus} />
|
||||
<OcrAbzeichen status={eintrag.ocrStatus} />
|
||||
{eintrag.tags.map((tag) => (
|
||||
<TagAbzeichen key={tag.id} name={tag.name} />
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Tablet und Rechner */}
|
||||
<div className="hidden overflow-x-auto rounded-lg border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[38%]">Dokument</TableHead>
|
||||
<TableHead>Absender</TableHead>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Posteingang</TableHead>
|
||||
<TableHead className="text-right">Datei</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{eintraege.map((eintrag) => (
|
||||
<TableRow key={eintrag.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/dokumente/${eintrag.id}`}
|
||||
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{eintrag.titel}
|
||||
</Link>
|
||||
{eintrag.aktenzeichen && (
|
||||
<p className="text-xs text-muted-foreground">Az. {eintrag.aktenzeichen}</p>
|
||||
)}
|
||||
<Ausschnitt text={eintrag.ausschnitt} />
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{eintrag.tags.map((tag) => (
|
||||
<TagAbzeichen key={tag.id} name={tag.name} />
|
||||
))}
|
||||
<OcrAbzeichen status={eintrag.ocrStatus} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">{eintrag.absender ?? '–'}</TableCell>
|
||||
<TableCell className="whitespace-nowrap align-top tabular-nums">
|
||||
{formatDatum(eintrag.eingangAm ?? eintrag.schreibenVom)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<ArtAbzeichen art={eintrag.art} />
|
||||
<RichtungAbzeichen richtung={eintrag.richtung} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<PosteingangAbzeichen status={eintrag.posteingangStatus} />
|
||||
{eintrag.anzahlVerknuepfungen > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Link2 className="size-3" aria-hidden="true" />
|
||||
{eintrag.anzahlVerknuepfungen} verknüpft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-right align-top text-xs text-muted-foreground">
|
||||
{eintrag.dateiname ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Paperclip className="size-3" aria-hidden="true" />
|
||||
{formatBytes(eintrag.groesseBytes ?? 0)}
|
||||
{eintrag.seitenzahl ? ` · ${eintrag.seitenzahl} S.` : ''}
|
||||
</span>
|
||||
) : (
|
||||
'ohne Datei'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Fundstelle aus der Volltextsuche. Postgres liefert sie mit
|
||||
* <mark>-Auszeichnung; hier wird sie kontrolliert wieder zusammengesetzt,
|
||||
* statt fremdes HTML in die Seite zu lassen.
|
||||
*/
|
||||
function Ausschnitt({ text }: { text?: string }) {
|
||||
if (!text) return null
|
||||
|
||||
const teile = text.split(/(<mark>|<\/mark>)/)
|
||||
let hervorgehoben = false
|
||||
|
||||
return (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{teile.map((teil, i) => {
|
||||
if (teil === '<mark>') {
|
||||
hervorgehoben = true
|
||||
return null
|
||||
}
|
||||
if (teil === '</mark>') {
|
||||
hervorgehoben = false
|
||||
return null
|
||||
}
|
||||
return hervorgehoben ? (
|
||||
<mark key={i} className="rounded bg-ampel-gelb/25 px-0.5 text-foreground">
|
||||
{teil}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{teil}</span>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, CalendarClock, CheckCircle2, Loader2, ScanText, Sparkles } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { ART_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import { DOKUMENT_ARTEN } from '@/lib/validierung/dokumente'
|
||||
import {
|
||||
dokumentSpeichern,
|
||||
fristUebernehmen,
|
||||
vorschlaegeLesen,
|
||||
type Aktionszustand,
|
||||
type Vorschlagsantwort,
|
||||
} from '@/server/actions/dokumente'
|
||||
|
||||
/**
|
||||
* Die Erkennungsvorschlaege zu einem frisch hochgeladenen Dokument.
|
||||
*
|
||||
* **Nichts hiervon wird automatisch uebernommen.** Jeder Wert steht als
|
||||
* Vorschlag da, mit der Textstelle, aus der er stammt, und wird erst durch
|
||||
* einen Klick verbindlich. Bei Fristen ist das keine Bequemlichkeit,
|
||||
* sondern eine Sicherheitsentscheidung: eine falsch gerechnete Frist, auf
|
||||
* die man sich verlaesst, ist schlimmer als gar keine.
|
||||
*
|
||||
* Die Texterkennung laeuft im Hintergrund weiter, waehrend diese Ansicht
|
||||
* schon steht - deshalb wird ein paar Mal nachgefragt, bis ein Ergebnis da
|
||||
* ist. Kommt keins (kein Tesseract auf dem Rechner), sagt die Ansicht das
|
||||
* und bietet die Eingabe von Hand an.
|
||||
*/
|
||||
export function ErkennungPanel({
|
||||
dokumentId,
|
||||
vorgaenge,
|
||||
}: {
|
||||
dokumentId: string
|
||||
vorgaenge: { id: string; titel: string }[]
|
||||
}) {
|
||||
const [antwort, setAntwort] = React.useState<Vorschlagsantwort | null>(null)
|
||||
const [laeuft, setLaeuft] = React.useState(true)
|
||||
|
||||
React.useEffect(() => {
|
||||
let abgebrochen = false
|
||||
let versuche = 0
|
||||
|
||||
async function nachfragen() {
|
||||
const ergebnis = await vorschlaegeLesen(dokumentId)
|
||||
if (abgebrochen) return
|
||||
|
||||
setAntwort(ergebnis)
|
||||
versuche += 1
|
||||
|
||||
const nochUnterwegs =
|
||||
!ergebnis.fehler &&
|
||||
(ergebnis.ocrStatus === 'ausstehend' || ergebnis.ocrStatus === 'laeuft') &&
|
||||
versuche < 15
|
||||
|
||||
if (nochUnterwegs) {
|
||||
// Erst schnell, dann geduldiger - ein Scan mit zwanzig Seiten
|
||||
// braucht seine Zeit.
|
||||
window.setTimeout(nachfragen, Math.min(1500 + versuche * 750, 6000))
|
||||
} else {
|
||||
setLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
void nachfragen()
|
||||
return () => {
|
||||
abgebrochen = true
|
||||
}
|
||||
}, [dokumentId])
|
||||
|
||||
if (laeuft && !antwort?.vorschlaege) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-card p-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
Der Text wird gelesen. Du kannst hier warten oder die Angaben gleich von Hand eintragen.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (antwort?.fehler) {
|
||||
return (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{antwort.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!antwort?.vorschlaege) {
|
||||
return (
|
||||
<Alert variant="hinweis">
|
||||
<ScanText aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{antwort?.ocrStatus === 'nicht_verfuegbar'
|
||||
? 'Auf diesem Rechner ist keine Texterkennung installiert (Tesseract). Das Dokument ist gespeichert – trage Absender, Datum und Aktenzeichen bitte von Hand ein.'
|
||||
: 'Aus diesem Dokument ließ sich kein Text lesen. Das kommt bei schlecht belichteten Fotos vor. Trage die Angaben bitte von Hand ein.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<MetadatenUebernehmen
|
||||
dokumentId={dokumentId}
|
||||
vorschlaege={antwort.vorschlaege}
|
||||
/>
|
||||
<Fristvorschlaege
|
||||
dokumentId={dokumentId}
|
||||
fristen={antwort.vorschlaege.fristen}
|
||||
vorgaenge={vorgaenge}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function MetadatenUebernehmen({
|
||||
dokumentId,
|
||||
vorschlaege,
|
||||
}: {
|
||||
dokumentId: string
|
||||
vorschlaege: NonNullable<Vorschlagsantwort['vorschlaege']>
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentSpeichern, {})
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4 rounded-lg border bg-card p-4" noValidate>
|
||||
<input type="hidden" name="dokumentId" value={dokumentId} />
|
||||
{/* Wird durch die Zuordnung ohnehin gesetzt - hier gesichtet. */}
|
||||
<input type="hidden" name="posteingangStatus" value="GESICHTET" />
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Das haben wir im Schreiben gelesen</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bitte prüfen und ändern, wo es nicht stimmt. Erst mit „Übernehmen“ wird es gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{zustand.hinweis && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.hinweis}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="v-titel" beschriftung="Titel" fehler={zustand.felder?.titel} pflicht>
|
||||
<Input name="titel" defaultValue={vorschlaege.titel ?? ''} maxLength={200} required />
|
||||
</Feld>
|
||||
|
||||
<Feld id="v-absender" beschriftung="Absender" fehler={zustand.felder?.absender}>
|
||||
<Input name="absender" defaultValue={vorschlaege.absender ?? ''} maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="v-art" beschriftung="Art">
|
||||
<Select name="art" defaultValue={vorschlaege.art ?? 'SONSTIGES'}>
|
||||
{DOKUMENT_ARTEN.map((art) => (
|
||||
<option key={art} value={art}>
|
||||
{ART_BESCHRIFTUNG[art]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="v-richtung" beschriftung="Richtung">
|
||||
<Select name="richtung" defaultValue={vorschlaege.richtung ?? 'EINGANG'}>
|
||||
<option value="EINGANG">Eingang</option>
|
||||
<option value="AUSGANG">Ausgang</option>
|
||||
<option value="INTERN">Intern</option>
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="v-schreibenVom"
|
||||
beschriftung="Datum des Schreibens"
|
||||
erklaerung="Das Datum, das oben auf dem Brief steht."
|
||||
fehler={zustand.felder?.schreibenVom}
|
||||
>
|
||||
<Input name="schreibenVom" type="date" defaultValue={vorschlaege.schreibenVom ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="v-aktenzeichen" beschriftung="Aktenzeichen / Nummer">
|
||||
<Input name="aktenzeichen" defaultValue={vorschlaege.aktenzeichen ?? ''} maxLength={100} />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<UebernehmenKnopf />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function UebernehmenKnopf() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <CheckCircle2 aria-hidden="true" />}
|
||||
Vorschläge übernehmen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function Fristvorschlaege({
|
||||
dokumentId,
|
||||
fristen,
|
||||
vorgaenge,
|
||||
}: {
|
||||
dokumentId: string
|
||||
fristen: NonNullable<Vorschlagsantwort['vorschlaege']>['fristen']
|
||||
vorgaenge: { id: string; titel: string }[]
|
||||
}) {
|
||||
if (fristen.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<CalendarClock className="mt-0.5 size-5 shrink-0 text-ampel-orange" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
{fristen.length === 1 ? 'Mögliche Frist im Text' : `${fristen.length} mögliche Fristen im Text`}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nichts davon ist angelegt. Prüfe die Textstelle und übernimm nur, was wirklich eine
|
||||
Frist ist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fristen.map((frist, i) => (
|
||||
<Fristkarte key={i} dokumentId={dokumentId} frist={frist} vorgaenge={vorgaenge} />
|
||||
))}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 84 SGG, § 70 VwGO" kurz />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fristkarte({
|
||||
dokumentId,
|
||||
frist,
|
||||
vorgaenge,
|
||||
}: {
|
||||
dokumentId: string
|
||||
frist: NonNullable<Vorschlagsantwort['vorschlaege']>['fristen'][number]
|
||||
vorgaenge: { id: string; titel: string }[]
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(fristUebernehmen, {})
|
||||
const [offen, setOffen] = React.useState(false)
|
||||
|
||||
if (zustand.hinweis) {
|
||||
return (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.hinweis}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{frist.titel}</p>
|
||||
<p className="text-sm text-muted-foreground">{frist.begruendung}</p>
|
||||
</div>
|
||||
{frist.rechtsbehelf && <Badge variant="rot">Rechtsbehelfsfrist</Badge>}
|
||||
</div>
|
||||
|
||||
<blockquote className="mt-2 border-l-2 border-border pl-3 text-sm italic text-muted-foreground">
|
||||
{frist.textstelle}
|
||||
</blockquote>
|
||||
|
||||
{zustand.fehler && (
|
||||
<p className="mt-2 text-sm font-medium text-destructive">{zustand.fehler}</p>
|
||||
)}
|
||||
|
||||
{!offen ? (
|
||||
<Button type="button" variant="outline" className="mt-3" onClick={() => setOffen(true)}>
|
||||
<CalendarClock aria-hidden="true" />
|
||||
Als Frist übernehmen
|
||||
</Button>
|
||||
) : (
|
||||
<form action={aktion} className="mt-3 space-y-3" noValidate>
|
||||
<input type="hidden" name="dokumentId" value={dokumentId} />
|
||||
<input type="hidden" name="beschreibung" value={frist.begruendung} />
|
||||
<input type="hidden" name="typ" value={frist.rechtsbehelf ? 'GESETZLICH' : 'BEHOERDLICH'} />
|
||||
{frist.rechtsbehelf && <input type="hidden" name="ausschlussfrist" value="ja" />}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld id={`f-titel-${dokumentId}`} beschriftung="Titel der Frist" pflicht>
|
||||
<Input name="titel" defaultValue={frist.titel} maxLength={200} required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`f-datum-${dokumentId}`}
|
||||
beschriftung="Fällig am"
|
||||
erklaerung="Bitte gegen das Schreiben prüfen. Bei Bescheiden zählt die Bekanntgabe, nicht das Briefdatum."
|
||||
pflicht
|
||||
>
|
||||
<Input name="datum" type="date" defaultValue={frist.datum ?? ''} required />
|
||||
</Feld>
|
||||
|
||||
{vorgaenge.length > 0 && (
|
||||
<Feld id={`f-vorgang-${dokumentId}`} beschriftung="Zu welchem Vorgang?">
|
||||
<Select name="vorgangId" defaultValue="">
|
||||
<option value="">Keinem Vorgang zuordnen</option>
|
||||
{vorgaenge.map((vorgang) => (
|
||||
<option key={vorgang.id} value={vorgang.id}>
|
||||
{vorgang.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
<Feld id={`f-grundlage-${dokumentId}`} beschriftung="Rechtsgrundlage (optional)">
|
||||
<Input
|
||||
name="rechtsgrundlage"
|
||||
defaultValue={frist.rechtsbehelf ? '§ 84 SGG' : ''}
|
||||
maxLength={120}
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<FristKnopf />
|
||||
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FristKnopf() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <CalendarClock aria-hidden="true" />}
|
||||
Frist anlegen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Link from 'next/link'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import {
|
||||
DOKUMENT_ARTEN,
|
||||
DOKUMENT_RICHTUNGEN,
|
||||
POSTEINGANG_BESCHRIFTUNG,
|
||||
POSTEINGANG_STUFEN,
|
||||
} from '@/lib/validierung/dokumente'
|
||||
|
||||
/**
|
||||
* Suche und Filter ueber der Dokumentenliste.
|
||||
*
|
||||
* Bewusst ein schlichtes GET-Formular ohne JavaScript: die Filter stehen
|
||||
* damit in der Adresszeile, lassen sich als Lesezeichen ablegen ("alle
|
||||
* offenen Bescheide vom Bezirk") und funktionieren auch, wenn im Zug das
|
||||
* Netz wackelt.
|
||||
*/
|
||||
export function FilterLeiste({
|
||||
ziel = '/dokumente',
|
||||
werte,
|
||||
tags,
|
||||
mitPosteingang = true,
|
||||
}: {
|
||||
ziel?: string
|
||||
werte: {
|
||||
suche?: string | null
|
||||
art?: string | null
|
||||
richtung?: string | null
|
||||
status?: string | null
|
||||
von?: string | null
|
||||
bis?: string | null
|
||||
tag?: string | null
|
||||
}
|
||||
tags: { id: string; name: string }[]
|
||||
mitPosteingang?: boolean
|
||||
}) {
|
||||
const etwasGesetzt = Object.values(werte).some((wert) => wert)
|
||||
|
||||
return (
|
||||
<form method="get" action={ziel} className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<Feld
|
||||
id="suche"
|
||||
beschriftung="Volltextsuche"
|
||||
erklaerung="Sucht in Titel, Absender, Aktenzeichen und im erkannten Text der Dokumente. Mehrere Wörter suchen alle; „Wortgruppe“ in Anführungszeichen sucht genau so; ein Minus davor schließt aus."
|
||||
>
|
||||
<Input
|
||||
name="suche"
|
||||
type="search"
|
||||
defaultValue={werte.suche ?? ''}
|
||||
placeholder="z. B. Hilfe zur Pflege"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Feld id="art" beschriftung="Art">
|
||||
<Select name="art" defaultValue={werte.art ?? ''}>
|
||||
<option value="">Alle Arten</option>
|
||||
{DOKUMENT_ARTEN.map((art) => (
|
||||
<option key={art} value={art}>
|
||||
{ART_BESCHRIFTUNG[art]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="richtung" beschriftung="Richtung">
|
||||
<Select name="richtung" defaultValue={werte.richtung ?? ''}>
|
||||
<option value="">Ein- und Ausgang</option>
|
||||
{DOKUMENT_RICHTUNGEN.map((richtung) => (
|
||||
<option key={richtung} value={richtung}>
|
||||
{RICHTUNG_BESCHRIFTUNG[richtung]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
{mitPosteingang && (
|
||||
<Feld id="status" beschriftung="Posteingang">
|
||||
<Select name="status" defaultValue={werte.status ?? ''}>
|
||||
<option value="">Alle Stufen</option>
|
||||
{POSTEINGANG_STUFEN.map((stufe) => (
|
||||
<option key={stufe} value={stufe}>
|
||||
{POSTEINGANG_BESCHRIFTUNG[stufe]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
<Feld id="von" beschriftung="Zeitraum von" erklaerung="Nach Eingangsdatum.">
|
||||
<Input name="von" type="date" defaultValue={werte.von ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="bis" beschriftung="Zeitraum bis">
|
||||
<Input name="bis" type="date" defaultValue={werte.bis ?? ''} />
|
||||
</Feld>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Feld id="tag" beschriftung="Schlagwort">
|
||||
<Select name="tag" defaultValue={werte.tag ?? ''}>
|
||||
<option value="">Alle Schlagworte</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">
|
||||
<Search aria-hidden="true" />
|
||||
Suchen
|
||||
</Button>
|
||||
{etwasGesetzt && (
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href={ziel}>
|
||||
<X aria-hidden="true" />
|
||||
Filter zurücksetzen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, CheckCircle2, Loader2, Save } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld, Feldgruppe } from '@/components/feld'
|
||||
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import {
|
||||
DOKUMENT_ARTEN,
|
||||
DOKUMENT_RICHTUNGEN,
|
||||
POSTEINGANG_BESCHRIFTUNG,
|
||||
POSTEINGANG_STUFEN,
|
||||
} from '@/lib/validierung/dokumente'
|
||||
import { dokumentSpeichern, type Aktionszustand } from '@/server/actions/dokumente'
|
||||
|
||||
export type MetadatenWerte = {
|
||||
id: string
|
||||
titel: string
|
||||
art: string
|
||||
richtung: string
|
||||
schreibenVom: string
|
||||
eingangAm: string
|
||||
versandAm: string
|
||||
absender: string
|
||||
empfaenger: string
|
||||
aktenzeichen: string
|
||||
ablageortPapier: string
|
||||
wiedervorlageAm: string
|
||||
ablaufAm: string
|
||||
posteingangStatus: string
|
||||
notizen: string
|
||||
tagIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Metadaten eines Dokuments.
|
||||
*
|
||||
* Das Feld, das hier am leichtesten uebersehen wird und trotzdem das
|
||||
* wichtigste ist: **der Ablageort des Originals**. Diese Anwendung ersetzt
|
||||
* den Leitz-Ordner nicht - sie sagt, in welchem er liegt. Wenn das Gericht
|
||||
* das Original sehen will, muss man es finden.
|
||||
*/
|
||||
export function MetadatenFormular({
|
||||
werte,
|
||||
tags,
|
||||
schreibrecht,
|
||||
}: {
|
||||
werte: MetadatenWerte
|
||||
tags: { id: string; name: string }[]
|
||||
schreibrecht: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentSpeichern, {})
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-6" noValidate>
|
||||
<input type="hidden" name="dokumentId" value={werte.id} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{zustand.hinweis && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.hinweis}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset disabled={!schreibrecht} className="space-y-6">
|
||||
<Feldgruppe titel="Das Schreiben">
|
||||
<Feld
|
||||
id="titel"
|
||||
beschriftung="Titel"
|
||||
erklaerung="Woran erkennst du dieses Dokument in einem Jahr wieder?"
|
||||
fehler={zustand.felder?.titel}
|
||||
pflicht
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input name="titel" defaultValue={werte.titel} maxLength={200} required />
|
||||
</Feld>
|
||||
|
||||
<Feld id="art" beschriftung="Art">
|
||||
<Select name="art" defaultValue={werte.art}>
|
||||
{DOKUMENT_ARTEN.map((art) => (
|
||||
<option key={art} value={art}>
|
||||
{ART_BESCHRIFTUNG[art]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="richtung" beschriftung="Richtung">
|
||||
<Select name="richtung" defaultValue={werte.richtung}>
|
||||
{DOKUMENT_RICHTUNGEN.map((richtung) => (
|
||||
<option key={richtung} value={richtung}>
|
||||
{RICHTUNG_BESCHRIFTUNG[richtung]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="absender" beschriftung="Absender" fehler={zustand.felder?.absender}>
|
||||
<Input name="absender" defaultValue={werte.absender} maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="empfaenger" beschriftung="Empfänger">
|
||||
<Input name="empfaenger" defaultValue={werte.empfaenger} maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="aktenzeichen"
|
||||
beschriftung="Aktenzeichen / Nummer"
|
||||
erklaerung="Gerichtsaktenzeichen, Kundennummer, Versicherungsschein-Nr. – was das Schreiben eben trägt."
|
||||
>
|
||||
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen} maxLength={100} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Daten"
|
||||
beschreibung="Für Rechtsbehelfsfristen zählt der Eingang, nicht das Datum auf dem Brief."
|
||||
>
|
||||
<Feld id="schreibenVom" beschriftung="Datum des Schreibens">
|
||||
<Input name="schreibenVom" type="date" defaultValue={werte.schreibenVom} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="eingangAm"
|
||||
beschriftung="Eingegangen am"
|
||||
fehler={zustand.felder?.eingangAm}
|
||||
erklaerung="Der Tag, an dem der Brief bei dir im Kasten lag."
|
||||
>
|
||||
<Input name="eingangAm" type="date" defaultValue={werte.eingangAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="versandAm" beschriftung="Versandt am" erklaerung="Nur bei ausgehender Post.">
|
||||
<Input name="versandAm" type="date" defaultValue={werte.versandAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="wiedervorlageAm"
|
||||
beschriftung="Wiedervorlage am"
|
||||
erklaerung="Wann willst du dieses Blatt wiedersehen?"
|
||||
>
|
||||
<Input name="wiedervorlageAm" type="date" defaultValue={werte.wiedervorlageAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="ablaufAm"
|
||||
beschriftung="Gültig bis"
|
||||
erklaerung="z. B. Ablauf des Betreuerausweises oder der Zuzahlungsbefreiung."
|
||||
>
|
||||
<Input name="ablaufAm" type="date" defaultValue={werte.ablaufAm} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe titel="Ablage">
|
||||
<Feld
|
||||
id="ablageortPapier"
|
||||
beschriftung="Ablageort des Originals"
|
||||
erklaerung="Ordner und Register, in dem das Blatt in Papier liegt. Papier bleibt Papier – und das Gericht fragt danach."
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input
|
||||
name="ablageortPapier"
|
||||
defaultValue={werte.ablageortPapier}
|
||||
maxLength={150}
|
||||
placeholder="Ordner 2, Register Bezirk Oberfranken"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld id="posteingangStatus" beschriftung="Stand im Posteingang">
|
||||
<Select name="posteingangStatus" defaultValue={werte.posteingangStatus}>
|
||||
{POSTEINGANG_STUFEN.map((stufe) => (
|
||||
<option key={stufe} value={stufe}>
|
||||
{POSTEINGANG_BESCHRIFTUNG[stufe]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="neueTags"
|
||||
beschriftung="Neue Schlagworte"
|
||||
erklaerung="Mit Komma getrennt, z. B. „Heimkosten, wichtig“."
|
||||
>
|
||||
<Input name="neueTags" maxLength={200} placeholder="Heimkosten, wichtig" />
|
||||
</Feld>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<fieldset className="sm:col-span-2">
|
||||
<legend className="mb-2 text-sm font-medium">Vorhandene Schlagworte</legend>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tags.map((tag) => (
|
||||
<label key={tag.id} className="flex min-h-11 items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
name="tagIds"
|
||||
value={tag.id}
|
||||
defaultChecked={werte.tagIds.includes(tag.id)}
|
||||
/>
|
||||
{tag.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<Feld
|
||||
id="notizen"
|
||||
beschriftung="Notizen"
|
||||
erklaerung="Werden verschlüsselt gespeichert."
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
</fieldset>
|
||||
|
||||
{schreibrecht && <SpeichernKnopf />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeichernKnopf() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" size="lg" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Save aria-hidden="true" />}
|
||||
Speichern
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import type { PosteingangStatus } from '@prisma/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { posteingangSetzen } from '@/server/actions/dokumente'
|
||||
import { POSTEINGANG_BESCHRIFTUNG, POSTEINGANG_STUFEN } from '@/lib/validierung/dokumente'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Das Posteingangsbuch als Kette: NEU -> GESICHTET -> ZUGEORDNET ->
|
||||
* BEANTWORTET.
|
||||
*
|
||||
* Diese vier Stufen sind kein Selbstzweck. Sie beantworten die Frage, die
|
||||
* man sich am Kuechentisch mit dem Poststapel stellt: Was habe ich schon
|
||||
* gelesen, was noch nicht, und was wartet noch auf eine Antwort? Nichts
|
||||
* darf durchrutschen - dafuer gibt es diese Zeile.
|
||||
*/
|
||||
export function PosteingangStufen({
|
||||
dokumentId,
|
||||
status,
|
||||
schreibrecht,
|
||||
kompakt,
|
||||
}: {
|
||||
dokumentId: string
|
||||
status: PosteingangStatus
|
||||
schreibrecht: boolean
|
||||
kompakt?: boolean
|
||||
}) {
|
||||
const [laeuft, setLaeuft] = React.useState<string | null>(null)
|
||||
const [aktuell, setAktuell] = React.useState<PosteingangStatus>(status)
|
||||
|
||||
React.useEffect(() => setAktuell(status), [status])
|
||||
|
||||
const erreicht = POSTEINGANG_STUFEN.indexOf(aktuell)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
role="group"
|
||||
aria-label="Stand im Posteingang"
|
||||
>
|
||||
{POSTEINGANG_STUFEN.map((stufe, i) => {
|
||||
const istAktuell = stufe === aktuell
|
||||
const istErledigt = i < erreicht
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={stufe}
|
||||
type="button"
|
||||
size={kompakt ? 'sm' : 'default'}
|
||||
variant={istAktuell ? 'default' : istErledigt ? 'secondary' : 'outline'}
|
||||
disabled={!schreibrecht || laeuft !== null || istAktuell}
|
||||
aria-current={istAktuell ? 'step' : undefined}
|
||||
className={cn(!schreibrecht && 'pointer-events-none')}
|
||||
onClick={async () => {
|
||||
setLaeuft(stufe)
|
||||
const ergebnis = await posteingangSetzen(dokumentId, stufe)
|
||||
if (!ergebnis.fehler) setAktuell(stufe)
|
||||
setLaeuft(null)
|
||||
}}
|
||||
>
|
||||
{laeuft === stufe ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : istErledigt ? (
|
||||
<Check aria-hidden="true" />
|
||||
) : null}
|
||||
{POSTEINGANG_BESCHRIFTUNG[stufe]}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import {
|
||||
AlertCircle,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
FileUp,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { ACCEPT_ATTRIBUT, formatBytes } from '@/lib/speicher/dateityp'
|
||||
import { dokumenteHochladen, type Aktionszustand } from '@/server/actions/dokumente'
|
||||
import { ErkennungPanel } from '@/components/dokumente/erkennung-panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Dokumente aufnehmen: per Drag&Drop, per Dateiauswahl oder direkt mit der
|
||||
* Handykamera.
|
||||
*
|
||||
* Das Ziel dieser Seite in einem Satz: **Foto hochladen, in zwei Klicks
|
||||
* einem Vorgang zuordnen, daraus eine Frist erzeugen.** Alles andere ist
|
||||
* Beiwerk. Deshalb sind Titel, Art und Absender optional - was der Brief
|
||||
* hergibt, schlaegt die Erkennung anschliessend selbst vor.
|
||||
*/
|
||||
export function UploadFormular({
|
||||
vorgaenge,
|
||||
vorgangVorgewaehlt,
|
||||
kameraZuerst,
|
||||
}: {
|
||||
vorgaenge: { id: string; titel: string }[]
|
||||
vorgangVorgewaehlt?: string | null
|
||||
/** Aufruf aus der Schnellerfassung "Beleg fotografieren". */
|
||||
kameraZuerst?: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumenteHochladen, {})
|
||||
const [dateien, setDateien] = React.useState<File[]>([])
|
||||
const [ueberzogen, setUeberzogen] = React.useState(false)
|
||||
|
||||
const dateiFeld = React.useRef<HTMLInputElement>(null)
|
||||
const kameraFeld = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
/**
|
||||
* Die ausgewaehlten Dateien werden in einem eigenen Zustand gehalten und
|
||||
* vor dem Absenden in das versteckte Feld gespiegelt. Nur so lassen sich
|
||||
* Dateiauswahl, Kamera und Drag&Drop zu einem Stapel zusammenfuehren.
|
||||
*/
|
||||
const uebernehmen = React.useCallback((neue: FileList | null) => {
|
||||
if (!neue || neue.length === 0) return
|
||||
setDateien((bisher) => {
|
||||
const zusammen = [...bisher]
|
||||
for (const datei of Array.from(neue)) {
|
||||
const schonDa = zusammen.some(
|
||||
(d) => d.name === datei.name && d.size === datei.size && d.lastModified === datei.lastModified,
|
||||
)
|
||||
if (!schonDa) zusammen.push(datei)
|
||||
}
|
||||
return zusammen.slice(0, 30)
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!dateiFeld.current) return
|
||||
const behaelter = new DataTransfer()
|
||||
for (const datei of dateien) behaelter.items.add(datei)
|
||||
dateiFeld.current.files = behaelter.files
|
||||
}, [dateien])
|
||||
|
||||
// Nach einem erfolgreichen Stapel die Auswahl leeren.
|
||||
React.useEffect(() => {
|
||||
if (zustand.ergebnisse && zustand.ergebnisse.some((e) => e.dokumentId)) {
|
||||
setDateien([])
|
||||
}
|
||||
}, [zustand.ergebnisse])
|
||||
|
||||
const gesamt = dateien.reduce((summe, datei) => summe + datei.size, 0)
|
||||
const einzelnHochgeladen =
|
||||
zustand.ergebnisse?.filter((e) => e.dokumentId).length === 1
|
||||
? zustand.ergebnisse.find((e) => e.dokumentId)!.dokumentId!
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form action={aktion} className="space-y-6" noValidate>
|
||||
<input
|
||||
ref={dateiFeld}
|
||||
type="file"
|
||||
name="dateien"
|
||||
multiple
|
||||
accept={ACCEPT_ATTRIBUT}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(ereignis) => uebernehmen(ereignis.target.files)}
|
||||
/>
|
||||
{/* Getrenntes Feld fuer die Kamera: capture darf nicht am Sammelfeld
|
||||
haengen, sonst bietet das Handy keine Dateiauswahl mehr an. */}
|
||||
<input
|
||||
ref={kameraFeld}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(ereignis) => {
|
||||
uebernehmen(ereignis.target.files)
|
||||
ereignis.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={(ereignis) => {
|
||||
ereignis.preventDefault()
|
||||
setUeberzogen(true)
|
||||
}}
|
||||
onDragLeave={() => setUeberzogen(false)}
|
||||
onDrop={(ereignis) => {
|
||||
ereignis.preventDefault()
|
||||
setUeberzogen(false)
|
||||
uebernehmen(ereignis.dataTransfer.files)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border-2 border-dashed p-6 text-center transition-colors sm:p-10',
|
||||
ueberzogen ? 'border-primary bg-primary/5' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<FileUp className="mx-auto mb-3 size-8 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="font-medium">Dokumente hierher ziehen</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
PDF, JPEG, PNG, WEBP, TIFF oder HEIC. Mehrere auf einmal sind ausdrücklich erwünscht.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-col justify-center gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kameraZuerst ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
onClick={() => kameraFeld.current?.click()}
|
||||
>
|
||||
<Camera aria-hidden="true" />
|
||||
Foto aufnehmen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={kameraZuerst ? 'outline' : 'default'}
|
||||
size="lg"
|
||||
onClick={() => dateiFeld.current?.click()}
|
||||
>
|
||||
<Upload aria-hidden="true" />
|
||||
Dateien auswählen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dateien.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5 text-sm">
|
||||
<span className="font-medium">
|
||||
{dateien.length} {dateien.length === 1 ? 'Datei' : 'Dateien'} · {formatBytes(gesamt)}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setDateien([])}>
|
||||
Auswahl leeren
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{dateien.map((datei, i) => (
|
||||
<li key={`${datei.name}-${i}`} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{datei.name}</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(datei.size)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDateien((bisher) => bisher.filter((_, index) => index !== i))}
|
||||
aria-label={`${datei.name} aus der Auswahl entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="titel"
|
||||
beschriftung="Titel"
|
||||
erklaerung="Nur bei einer einzelnen Datei. Leer lassen ist in Ordnung – die Erkennung schlägt gleich einen Titel vor."
|
||||
fehler={zustand.felder?.titel}
|
||||
>
|
||||
<Input name="titel" maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="eingangAm"
|
||||
beschriftung="Eingegangen am"
|
||||
erklaerung="Maßgeblich für Rechtsbehelfsfristen – nicht das Datum auf dem Brief. Leer = heute."
|
||||
fehler={zustand.felder?.eingangAm}
|
||||
>
|
||||
<Input name="eingangAm" type="date" />
|
||||
</Feld>
|
||||
|
||||
<Feld id="vorgangId" beschriftung="Direkt einem Vorgang zuordnen">
|
||||
<Select name="vorgangId" defaultValue={vorgangVorgewaehlt ?? ''}>
|
||||
<option value="">Später zuordnen</option>
|
||||
{vorgaenge.map((vorgang) => (
|
||||
<option key={vorgang.id} value={vorgang.id}>
|
||||
{vorgang.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="ablageortPapier"
|
||||
beschriftung="Ablageort des Originals"
|
||||
erklaerung="Wo liegt das Blatt in Papier? z. B. „Ordner 2, Register Bezirk“. Papier bleibt Papier."
|
||||
>
|
||||
<Input name="ablageortPapier" maxLength={150} placeholder="Ordner 2, Register Bezirk" />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Absendeknopf anzahl={dateien.length} />
|
||||
</form>
|
||||
|
||||
{zustand.ergebnisse && zustand.ergebnisse.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Alert variant={zustand.ergebnisse.every((e) => e.dokumentId) ? 'erfolg' : 'warnung'}>
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.hinweis ?? 'Verarbeitet.'}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{zustand.ergebnisse.map((ergebnis, i) => (
|
||||
<li key={i} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate">{ergebnis.dateiname}</span>
|
||||
{ergebnis.dokumentId ? (
|
||||
<Link
|
||||
href={`/dokumente/${ergebnis.dokumentId}`}
|
||||
className="shrink-0 font-medium text-primary hover:underline"
|
||||
>
|
||||
Öffnen
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-destructive">{ergebnis.fehler}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{einzelnHochgeladen && (
|
||||
<ErkennungPanel dokumentId={einzelnHochgeladen} vorgaenge={vorgaenge} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Absendeknopf({ anzahl }: { anzahl: number }) {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={pending || anzahl === 0}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
Wird übernommen …
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload aria-hidden="true" />
|
||||
{anzahl <= 1 ? 'In die Akte übernehmen' : `${anzahl} Dokumente übernehmen`}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, Link2, Link2Off, Loader2, Plus } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { ZIEL_BESCHRIFTUNG, type Verknuepfungsziel } from '@/lib/validierung/dokumente'
|
||||
import {
|
||||
dokumentVerknuepfen,
|
||||
verknuepfungLoesen,
|
||||
type Aktionszustand,
|
||||
} from '@/server/actions/dokumente'
|
||||
|
||||
export type Verknuepfungseintrag = {
|
||||
id: string
|
||||
ziel: Verknuepfungsziel
|
||||
zielId: string
|
||||
bezeichnung: string
|
||||
bemerkung: string | null
|
||||
/** Wohin fuehrt der Link in der Anwendung? */
|
||||
href: string | null
|
||||
}
|
||||
|
||||
export type Zielauswahl = {
|
||||
ziel: Verknuepfungsziel
|
||||
eintraege: { id: string; titel: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Verknuepfungen eines Dokuments mit der uebrigen Akte.
|
||||
*
|
||||
* Ein Bescheid gehoert zum Vorgang "Hilfe zur Pflege", begruendet eine
|
||||
* Widerspruchsfrist und belegt eine Buchung. Erst diese Verbindungen
|
||||
* machen aus einem Stapel Dateien eine Akte, in der man einen Sachverhalt
|
||||
* von jeder Seite aus wiederfindet.
|
||||
*/
|
||||
export function Verknuepfungen({
|
||||
dokumentId,
|
||||
vorhandene,
|
||||
auswahl,
|
||||
schreibrecht,
|
||||
}: {
|
||||
dokumentId: string
|
||||
vorhandene: Verknuepfungseintrag[]
|
||||
auswahl: Zielauswahl[]
|
||||
schreibrecht: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentVerknuepfen, {})
|
||||
const [gewaehlt, setGewaehlt] = React.useState<Verknuepfungsziel>(auswahl[0]?.ziel ?? 'vorgang')
|
||||
|
||||
const moeglich = auswahl.find((eintrag) => eintrag.ziel === gewaehlt)?.eintraege ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{vorhandene.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={Link2}
|
||||
titel="Noch nicht zugeordnet"
|
||||
beschreibung="Ordne das Dokument einem Vorgang zu – dann taucht es dort auf, wo du es suchst."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{vorhandene.map((eintrag) => (
|
||||
<li key={eintrag.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{ZIEL_BESCHRIFTUNG[eintrag.ziel]}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{eintrag.href ? (
|
||||
<Link href={eintrag.href} className="font-medium hover:underline">
|
||||
{eintrag.bezeichnung}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">{eintrag.bezeichnung}</span>
|
||||
)}
|
||||
{eintrag.bemerkung && (
|
||||
<span className="block text-sm text-muted-foreground">{eintrag.bemerkung}</span>
|
||||
)}
|
||||
</span>
|
||||
{schreibrecht && <LoesenKnopf verknuepfungId={eintrag.id} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{schreibrecht && (
|
||||
<form action={aktion} className="space-y-4 rounded-lg border bg-card p-4" noValidate>
|
||||
<input type="hidden" name="dokumentId" value={dokumentId} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="ziel" beschriftung="Womit verknüpfen?">
|
||||
<Select
|
||||
name="ziel"
|
||||
value={gewaehlt}
|
||||
onChange={(ereignis) => setGewaehlt(ereignis.target.value as Verknuepfungsziel)}
|
||||
>
|
||||
{auswahl.map((eintrag) => (
|
||||
<option key={eintrag.ziel} value={eintrag.ziel}>
|
||||
{ZIEL_BESCHRIFTUNG[eintrag.ziel]} ({eintrag.eintraege.length})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="zielId"
|
||||
beschriftung={ZIEL_BESCHRIFTUNG[gewaehlt]}
|
||||
erklaerung={moeglich.length === 0 ? 'Hier gibt es noch nichts zum Verknüpfen.' : undefined}
|
||||
>
|
||||
<Select name="zielId" disabled={moeglich.length === 0}>
|
||||
{moeglich.map((eintrag) => (
|
||||
<option key={eintrag.id} value={eintrag.id}>
|
||||
{eintrag.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="bemerkung" beschriftung="Bemerkung (optional)" className="sm:col-span-2">
|
||||
<Input name="bemerkung" maxLength={200} placeholder="z. B. Beleg zur Zahlung vom 12.03." />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<VerknuepfenKnopf gesperrt={moeglich.length === 0} />
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerknuepfenKnopf({ gesperrt }: { gesperrt: boolean }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending || gesperrt}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Plus aria-hidden="true" />}
|
||||
Verknüpfen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function LoesenKnopf({ verknuepfungId }: { verknuepfungId: string }) {
|
||||
const [laeuft, setLaeuft] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={laeuft}
|
||||
onClick={async () => {
|
||||
setLaeuft(true)
|
||||
await verknuepfungLoesen(verknuepfungId)
|
||||
setLaeuft(false)
|
||||
}}
|
||||
>
|
||||
{laeuft ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Link2Off aria-hidden="true" />
|
||||
)}
|
||||
Lösen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { AlertCircle, Trash2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Absendeknopf } from '@/components/finanzen/eingaben'
|
||||
import type { Formularzustand } from '@/server/actions/finanzen'
|
||||
|
||||
type Aktion = (zustand: Formularzustand, daten: FormData) => Promise<Formularzustand>
|
||||
|
||||
/**
|
||||
* Löschknopf mit Rückfrage.
|
||||
*
|
||||
* Gelöscht wird im Papierkorb-Sinn: geloeschtAm wird gesetzt, der
|
||||
* Datensatz bleibt 30 Tage bestehen. Bei Saldoständen und Wertständen
|
||||
* ist es dagegen ein echtes Entfernen – deshalb steht bei denen ein
|
||||
* deutlicherer Text.
|
||||
*/
|
||||
export function Loeschknopf({
|
||||
aktion,
|
||||
felder,
|
||||
beschriftung = 'Löschen',
|
||||
frage,
|
||||
variante = 'ghost',
|
||||
}: {
|
||||
aktion: Aktion
|
||||
felder: Record<string, string>
|
||||
beschriftung?: string
|
||||
frage: string
|
||||
variante?: 'ghost' | 'destructive' | 'outline'
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(aktion, {})
|
||||
const [gefragt, setGefragt] = React.useState(false)
|
||||
|
||||
if (!gefragt) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="button" variant={variante} size="sm" onClick={() => setGefragt(true)}>
|
||||
<Trash2 aria-hidden="true" />
|
||||
{beschriftung}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={absenden} className="space-y-2 rounded-md border border-destructive/50 p-3">
|
||||
{Object.entries(felder).map(([name, wert]) => (
|
||||
<input key={name} type="hidden" name={name} value={wert} />
|
||||
))}
|
||||
<p className="text-sm">{frage}</p>
|
||||
<div className="flex gap-2">
|
||||
<Absendeknopf variant="destructive" size="sm" wartetext="Wird gelöscht …">
|
||||
Ja, löschen
|
||||
</Absendeknopf>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle, FileWarning, Paperclip } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { cn, formatCent } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Kleine Bausteine, die in mehreren Finanzseiten vorkommen.
|
||||
*
|
||||
* Alle rein darstellend und ohne Zustand - sie laufen deshalb sowohl in
|
||||
* Server- als auch in Client-Komponenten. Betraege kommen hier immer als
|
||||
* Number in Cent an; die Umwandlung aus BigInt passiert vorher mit
|
||||
* centZuZahl().
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ein Geldbetrag. Ausgaben werden mit Minuszeichen UND in gedeckter
|
||||
* Farbe gezeigt - die Farbe allein waere fuer farbfehlsichtige Nutzer
|
||||
* keine Information.
|
||||
*/
|
||||
export function Betrag({
|
||||
cent,
|
||||
richtung,
|
||||
fett,
|
||||
className,
|
||||
}: {
|
||||
cent: number | null
|
||||
richtung?: 'EINNAHME' | 'AUSGABE' | 'UMBUCHUNG'
|
||||
fett?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
if (cent === null) {
|
||||
return <span className={cn('tabular-nums text-muted-foreground', className)}>nicht erfasst</span>
|
||||
}
|
||||
|
||||
const vorzeichenbehaftet = richtung === 'AUSGABE' ? -Math.abs(cent) : cent
|
||||
const negativ = vorzeichenbehaftet < 0
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
fett && 'font-semibold',
|
||||
negativ ? 'text-ampel-rot' : undefined,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{formatCent(vorzeichenbehaftet)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Kachel mit einer Zahl und einer Erklärung, was sie bedeutet. */
|
||||
export function Kennzahl({
|
||||
titel,
|
||||
wert,
|
||||
hinweis,
|
||||
ton = 'neutral',
|
||||
href,
|
||||
}: {
|
||||
titel: string
|
||||
wert: React.ReactNode
|
||||
hinweis?: React.ReactNode
|
||||
ton?: 'neutral' | 'gut' | 'achtung' | 'schlecht'
|
||||
href?: string
|
||||
}) {
|
||||
const rahmen =
|
||||
ton === 'gut'
|
||||
? 'border-ampel-gruen/40'
|
||||
: ton === 'achtung'
|
||||
? 'border-ampel-orange/50'
|
||||
: ton === 'schlecht'
|
||||
? 'border-ampel-rot/50'
|
||||
: undefined
|
||||
|
||||
const inhalt = (
|
||||
<CardContent className="space-y-1 p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">{titel}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{wert}</p>
|
||||
{hinweis && <p className="text-sm text-muted-foreground">{hinweis}</p>}
|
||||
</CardContent>
|
||||
)
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Card className={cn('transition-colors hover:bg-accent/40', rahmen)}>
|
||||
<Link href={href} className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{inhalt}
|
||||
</Link>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return <Card className={rahmen}>{inhalt}</Card>
|
||||
}
|
||||
|
||||
/**
|
||||
* Belegzustand einer Buchung. Der Belegzwang verbietet nichts - er macht
|
||||
* sichtbar. Eine Buchung ohne Beleg und ohne Begruendung ist die, nach
|
||||
* der beim Rechtspfleger gefragt wird.
|
||||
*/
|
||||
export function Belegzeichen({
|
||||
belegTitel,
|
||||
begruendung,
|
||||
belegId,
|
||||
}: {
|
||||
belegId: string | null
|
||||
belegTitel: string | null
|
||||
begruendung: string | null
|
||||
}) {
|
||||
if (belegId) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Paperclip className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{belegTitel ?? 'Beleg hinterlegt'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (begruendung && begruendung.trim() !== '') {
|
||||
return (
|
||||
<Badge variant="gelb" title={begruendung}>
|
||||
<FileWarning className="size-3.5" aria-hidden="true" />
|
||||
Beleg begründet fehlend
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="rot">
|
||||
<AlertTriangle className="size-3.5" aria-hidden="true" />
|
||||
Beleg fehlt
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Warnung, um die es bei der Trennung der Vermoegensmassen geht:
|
||||
* eine Buchung auf einem Konto des Betreuers, die trotzdem als Vermoegen
|
||||
* der betreuten Person gefuehrt wird.
|
||||
*/
|
||||
export function Vermischungswarnung({ kurz = false }: { kurz?: boolean }) {
|
||||
if (kurz) {
|
||||
return (
|
||||
<Badge variant="rot">
|
||||
<AlertTriangle className="size-3.5" aria-hidden="true" />
|
||||
Vermögen vermischt
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 rounded-md border border-ampel-rot/50 bg-ampel-rot/10 p-3">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-ampel-rot" aria-hidden="true" />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium">Vorsicht: hier vermischen sich zwei Vermögen.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Die Buchung läuft über ein Konto, das als <strong>Konto des Betreuers</strong> markiert
|
||||
ist, gilt aber als Vermögen der betreuten Person. Genau das fällt einem in der
|
||||
Rechnungslegung um die Ohren. Entweder ist das Konto falsch markiert, oder es ist eine
|
||||
eigene Auslage – dann bitte den Haken bei „betrifft das Vermögen der betreuten Person“
|
||||
entfernen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Zeile in einer Bestands- oder Summenaufstellung. */
|
||||
export function Summenzeile({
|
||||
beschriftung,
|
||||
cent,
|
||||
betont,
|
||||
}: {
|
||||
beschriftung: React.ReactNode
|
||||
cent: number
|
||||
betont?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-baseline justify-between gap-4 border-t py-2',
|
||||
betont && 'border-foreground/40 font-semibold',
|
||||
)}
|
||||
>
|
||||
<span>{beschriftung}</span>
|
||||
<Betrag cent={cent} fett={betont} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Hinweise aus einer Berechnung - Lücken werden benannt, nicht versteckt. */
|
||||
export function Hinweisliste({ hinweise, titel }: { hinweise: string[]; titel?: string }) {
|
||||
if (hinweise.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-md border border-ampel-orange/50 bg-ampel-orange/10 p-3">
|
||||
<p className="mb-1.5 text-sm font-medium">{titel ?? 'Das solltest du vor der Abgabe klären'}</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{hinweise.map((hinweis, i) => (
|
||||
<li key={i}>{hinweis}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Filter, RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import {
|
||||
ALLE_KATEGORIEN,
|
||||
BUCHUNG_RICHTUNGEN,
|
||||
KATEGORIE_BESCHRIFTUNG,
|
||||
RICHTUNG_BESCHRIFTUNG,
|
||||
} from '@/lib/finanzen/kategorien'
|
||||
|
||||
/**
|
||||
* Filter der Buchungsliste.
|
||||
*
|
||||
* Der Zustand steht in der Adresszeile, nicht in einem React-State: So
|
||||
* lässt sich eine gefilterte Ansicht als Lesezeichen ablegen („alle
|
||||
* Heimkosten 2026“) und beim Zurückgehen bleibt sie erhalten.
|
||||
*/
|
||||
export function Buchungsfilter({
|
||||
konten,
|
||||
aktiv,
|
||||
}: {
|
||||
konten: { id: string; bezeichnung: string }[]
|
||||
aktiv: {
|
||||
kontoId: string
|
||||
kategorie: string
|
||||
richtung: string
|
||||
von: string
|
||||
bis: string
|
||||
suche: string
|
||||
nurOhneBeleg: boolean
|
||||
nurEigeneAuslagen: boolean
|
||||
}
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const parameter = useSearchParams()
|
||||
const [offen, setOffen] = React.useState(
|
||||
Boolean(
|
||||
aktiv.kategorie ||
|
||||
aktiv.richtung ||
|
||||
aktiv.von ||
|
||||
aktiv.bis ||
|
||||
aktiv.nurOhneBeleg ||
|
||||
aktiv.nurEigeneAuslagen,
|
||||
),
|
||||
)
|
||||
|
||||
function anwenden(formular: FormData) {
|
||||
const neu = new URLSearchParams()
|
||||
for (const [schluessel, wert] of formular.entries()) {
|
||||
if (typeof wert === 'string' && wert !== '') neu.set(schluessel, wert)
|
||||
}
|
||||
router.push(`/finanzen/buchungen?${neu.toString()}`)
|
||||
}
|
||||
|
||||
const anzahlAktiv = [
|
||||
aktiv.kontoId,
|
||||
aktiv.kategorie,
|
||||
aktiv.richtung,
|
||||
aktiv.von,
|
||||
aktiv.bis,
|
||||
aktiv.suche,
|
||||
aktiv.nurOhneBeleg ? 'x' : '',
|
||||
aktiv.nurEigeneAuslagen ? 'x' : '',
|
||||
].filter(Boolean).length
|
||||
|
||||
return (
|
||||
<form action={anwenden} className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[16rem] flex-1">
|
||||
<Feld
|
||||
id="suche"
|
||||
beschriftung="Suche"
|
||||
erklaerung="Sucht in Gegenstelle und Kategorie. Der Verwendungszweck ist verschlüsselt und lässt sich nicht durchsuchen."
|
||||
>
|
||||
<Input name="suche" defaultValue={aktiv.suche} placeholder="Seniowohnpark, Rente …" />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[14rem]">
|
||||
<Feld id="kontoId" beschriftung="Konto">
|
||||
<Select name="kontoId" defaultValue={aktiv.kontoId}>
|
||||
<option value="">alle Konten</option>
|
||||
{konten.map((konto) => (
|
||||
<option key={konto.id} value={konto.id}>
|
||||
{konto.bezeichnung}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="secondary">
|
||||
<Filter aria-hidden="true" />
|
||||
Filtern
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOffen((wert) => !wert)}
|
||||
aria-expanded={offen}
|
||||
>
|
||||
{offen ? 'Weniger' : 'Mehr Filter'}
|
||||
{anzahlAktiv > 0 && !offen ? ` (${anzahlAktiv} aktiv)` : ''}
|
||||
</Button>
|
||||
|
||||
{parameter.size > 0 && (
|
||||
<Button type="button" variant="ghost" onClick={() => router.push('/finanzen/buchungen')}>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offen && (
|
||||
<div className="grid gap-4 border-t pt-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Feld id="richtung" beschriftung="Richtung">
|
||||
<Select name="richtung" defaultValue={aktiv.richtung}>
|
||||
<option value="">alle</option>
|
||||
{BUCHUNG_RICHTUNGEN.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{RICHTUNG_BESCHRIFTUNG[wert]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="kategorie" beschriftung="Kategorie">
|
||||
<Select name="kategorie" defaultValue={aktiv.kategorie}>
|
||||
<option value="">alle</option>
|
||||
{ALLE_KATEGORIEN.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{KATEGORIE_BESCHRIFTUNG[wert]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="von" beschriftung="Buchungen ab">
|
||||
<Input type="date" name="von" defaultValue={aktiv.von} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="bis" beschriftung="Buchungen bis">
|
||||
<Input type="date" name="bis" defaultValue={aktiv.bis} />
|
||||
</Feld>
|
||||
|
||||
<label className="flex items-center gap-3 sm:col-span-2">
|
||||
<Checkbox name="nurOhneBeleg" defaultChecked={aktiv.nurOhneBeleg} />
|
||||
<span className="text-sm">Nur Buchungen ohne Beleg und ohne Begründung</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 sm:col-span-2">
|
||||
<Checkbox name="nurEigeneAuslagen" defaultChecked={aktiv.nurEigeneAuslagen} />
|
||||
<span className="text-sm">Nur eigene Auslagen des Betreuers</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
|
||||
import { Vermischungswarnung } from '@/components/finanzen/bausteine'
|
||||
import {
|
||||
ALLE_KATEGORIEN,
|
||||
BUCHUNG_RICHTUNGEN,
|
||||
KATEGORIE_BESCHRIFTUNG,
|
||||
RICHTUNG_BESCHRIFTUNG,
|
||||
kategorienFuerRichtung,
|
||||
standardKategorie,
|
||||
} from '@/lib/finanzen/kategorien'
|
||||
import { buchungSpeichern, type Formularzustand } from '@/server/actions/finanzen'
|
||||
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
|
||||
|
||||
export type KontoAuswahl = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
istBetreuerkonto: boolean
|
||||
}
|
||||
|
||||
export type BelegAuswahl = {
|
||||
id: string
|
||||
titel: string
|
||||
}
|
||||
|
||||
export type BuchungWerte = {
|
||||
id: string
|
||||
kontoId: string | null
|
||||
datum: string
|
||||
wertstellung: string | null
|
||||
betrag: string
|
||||
richtung: BuchungRichtung
|
||||
kategorie: BuchungKategorie
|
||||
gegenstelle: string | null
|
||||
verwendungszweck: string | null
|
||||
belegId: string | null
|
||||
belegFehltBegruendung: string | null
|
||||
betrifftBetreute: boolean
|
||||
vermoegenswirksam: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchung erfassen oder ändern.
|
||||
*
|
||||
* Drei Dinge macht dieses Formular anders als eine Haushaltsbuch-App:
|
||||
*
|
||||
* 1. Es warnt sichtbar, wenn eine Buchung auf einem Konto des Betreuers
|
||||
* läuft und trotzdem als Vermögen der Betreuten gilt.
|
||||
* 2. Es lässt eine Buchung ohne Beleg zu – verlangt dann aber eine
|
||||
* Begründung, weil genau danach gefragt wird.
|
||||
* 3. Es trennt laufende Vorgänge von vermögenswirksamen. Ein
|
||||
* Verkaufserlös ist keine Einnahme im Sinne der Rechnungslegung.
|
||||
*/
|
||||
export function Buchungsformular({
|
||||
betreuteId,
|
||||
konten,
|
||||
belege,
|
||||
buchung,
|
||||
standardDatum,
|
||||
schnellerfassung = false,
|
||||
}: {
|
||||
betreuteId: string
|
||||
konten: KontoAuswahl[]
|
||||
belege: BelegAuswahl[]
|
||||
buchung?: BuchungWerte
|
||||
/** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */
|
||||
standardDatum?: string
|
||||
/** Nach dem Speichern bleibt das Formular für die nächste Buchung offen. */
|
||||
schnellerfassung?: boolean
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(buchungSpeichern, {})
|
||||
|
||||
const [kontoId, setKontoId] = React.useState(buchung?.kontoId ?? konten[0]?.id ?? '')
|
||||
const [richtung, setRichtung] = React.useState<BuchungRichtung>(buchung?.richtung ?? 'AUSGABE')
|
||||
const [kategorie, setKategorie] = React.useState<BuchungKategorie>(
|
||||
buchung?.kategorie ?? 'SONSTIGE_AUSGABE',
|
||||
)
|
||||
const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true)
|
||||
const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '')
|
||||
|
||||
const konto = konten.find((k) => k.id === kontoId)
|
||||
const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute
|
||||
const vorschlaege = kategorienFuerRichtung(richtung)
|
||||
|
||||
function richtungWechseln(neu: BuchungRichtung) {
|
||||
setRichtung(neu)
|
||||
// Die Kategorie folgt der Richtung, solange sie nicht dazu passt -
|
||||
// sonst steht "Heimkosten" unter den Einnahmen.
|
||||
if (!kategorienFuerRichtung(neu).includes(kategorie)) {
|
||||
setKategorie(standardKategorie(neu))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={absenden} className="space-y-6">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
{buchung && <input type="hidden" name="buchungId" value={buchung.id} />}
|
||||
{schnellerfassung && !buchung && <input type="hidden" name="weiterErfassen" value="on" />}
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Das hat nicht geklappt</AlertTitle>
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{zustand.erfolg && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertTitle>Gespeichert</AlertTitle>
|
||||
<AlertDescription>{zustand.erfolg}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="datum"
|
||||
beschriftung="Buchungsdatum"
|
||||
pflicht
|
||||
erklaerung="Der Tag, an dem das Geld geflossen ist."
|
||||
fehler={zustand.felder?.datum}
|
||||
>
|
||||
<Input
|
||||
type="date"
|
||||
name="datum"
|
||||
defaultValue={buchung?.datum ?? standardDatum}
|
||||
required
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="wertstellung"
|
||||
beschriftung="Wertstellung"
|
||||
erklaerung="Optional – nur, wenn sie vom Buchungsdatum abweicht."
|
||||
fehler={zustand.felder?.wertstellung}
|
||||
>
|
||||
<Input type="date" name="wertstellung" defaultValue={buchung?.wertstellung ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="betrag"
|
||||
beschriftung="Betrag"
|
||||
pflicht
|
||||
erklaerung="Immer positiv eintragen. Ob es Geld kostet oder bringt, sagt die Richtung."
|
||||
fehler={zustand.felder?.betrag}
|
||||
>
|
||||
<Betragseingabe id="betrag" name="betrag" standardwert={buchung?.betrag} pflicht />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="richtung"
|
||||
beschriftung="Richtung"
|
||||
pflicht
|
||||
erklaerung="Umbuchungen zwischen eigenen Konten zählen weder als Einnahme noch als Ausgabe."
|
||||
fehler={zustand.felder?.richtung}
|
||||
>
|
||||
<Select
|
||||
name="richtung"
|
||||
value={richtung}
|
||||
onChange={(ereignis) => richtungWechseln(ereignis.target.value as BuchungRichtung)}
|
||||
>
|
||||
{BUCHUNG_RICHTUNGEN.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{RICHTUNG_BESCHRIFTUNG[wert]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="kategorie"
|
||||
beschriftung="Kategorie"
|
||||
pflicht
|
||||
erklaerung="Nach diesen Kategorien ordnet die Rechnungslegung Einnahmen und Ausgaben."
|
||||
fehler={zustand.felder?.kategorie}
|
||||
>
|
||||
<Select
|
||||
name="kategorie"
|
||||
value={kategorie}
|
||||
onChange={(ereignis) => setKategorie(ereignis.target.value as BuchungKategorie)}
|
||||
>
|
||||
<optgroup label={`Passend zu „${RICHTUNG_BESCHRIFTUNG[richtung]}“`}>
|
||||
{vorschlaege.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{KATEGORIE_BESCHRIFTUNG[wert]}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Alle übrigen">
|
||||
{ALLE_KATEGORIEN.filter((wert) => !vorschlaege.includes(wert)).map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{KATEGORIE_BESCHRIFTUNG[wert]}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="kontoId"
|
||||
beschriftung="Konto"
|
||||
erklaerung="Ohne Konto lässt sich die Buchung später keinem Kontostand zuordnen."
|
||||
fehler={zustand.felder?.kontoId}
|
||||
>
|
||||
<Select
|
||||
name="kontoId"
|
||||
value={kontoId}
|
||||
onChange={(ereignis) => setKontoId(ereignis.target.value)}
|
||||
>
|
||||
<option value="">– kein Konto –</option>
|
||||
{konten.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.bezeichnung}
|
||||
{k.istBetreuerkonto ? ' (Konto des Betreuers)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
id="gegenstelle"
|
||||
beschriftung="Gegenstelle"
|
||||
erklaerung="Wer hat gezahlt oder wer wurde bezahlt? Steht später im Belegverzeichnis."
|
||||
fehler={zustand.felder?.gegenstelle}
|
||||
>
|
||||
<Input name="gegenstelle" defaultValue={buchung?.gegenstelle ?? ''} maxLength={160} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="verwendungszweck"
|
||||
beschriftung="Verwendungszweck"
|
||||
erklaerung="Wird verschlüsselt gespeichert."
|
||||
fehler={zustand.felder?.verwendungszweck}
|
||||
>
|
||||
<Textarea
|
||||
name="verwendungszweck"
|
||||
defaultValue={buchung?.verwendungszweck ?? ''}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
{/* ----------------------------------------------------------- */}
|
||||
<fieldset className="space-y-4 rounded-md border p-4">
|
||||
<legend className="px-1 text-sm font-medium">Beleg</legend>
|
||||
|
||||
<Feld
|
||||
id="belegId"
|
||||
beschriftung="Beleg aus den Dokumenten"
|
||||
erklaerung="Rechnung, Quittung oder Kontoauszug, der diese Buchung belegt."
|
||||
fehler={zustand.felder?.belegId}
|
||||
>
|
||||
<Select
|
||||
name="belegId"
|
||||
value={belegId}
|
||||
onChange={(ereignis) => setBelegId(ereignis.target.value)}
|
||||
>
|
||||
<option value="">– kein Beleg hinterlegt –</option>
|
||||
{belege.map((beleg) => (
|
||||
<option key={beleg.id} value={beleg.id}>
|
||||
{beleg.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
{belegId === '' && (
|
||||
<Feld
|
||||
id="belegFehltBegruendung"
|
||||
beschriftung="Warum fehlt der Beleg?"
|
||||
erklaerung="Ohne Beleg und ohne Begründung wird die Buchung in der Liste rot markiert und in der Belegquote gezählt. Verboten ist sie nicht – aber beim Gericht wird danach gefragt."
|
||||
fehler={zustand.felder?.belegFehltBegruendung}
|
||||
>
|
||||
<Input
|
||||
name="belegFehltBegruendung"
|
||||
defaultValue={buchung?.belegFehltBegruendung ?? ''}
|
||||
maxLength={300}
|
||||
placeholder="z.B. Barkauf im Kiosk, keine Quittung erhalten"
|
||||
/>
|
||||
</Feld>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* ----------------------------------------------------------- */}
|
||||
<fieldset className="space-y-4 rounded-md border p-4">
|
||||
<legend className="px-1 text-sm font-medium">Zuordnung</legend>
|
||||
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
name="betrifftBetreute"
|
||||
checked={betrifftBetreute}
|
||||
onChange={(ereignis) => setBetrifftBetreute(ereignis.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block font-medium">
|
||||
Betrifft das Vermögen der betreuten Person
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Abwählen, wenn du die Ausgabe aus eigener Tasche bezahlt hast. Solche Auslagen
|
||||
stehen in der Rechnungslegung getrennt und werden nach §§ 1877, 1878 BGB erstattet.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{vermischt && <Vermischungswarnung />}
|
||||
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
name="vermoegenswirksam"
|
||||
defaultChecked={buchung?.vermoegenswirksam}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block font-medium">Vermögenswirksam</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Für Vorgänge, die Vermögen verschieben statt laufend einzunehmen oder auszugeben –
|
||||
Verkaufserlös, ausgezahlter Rückkaufswert, Erbschaft. Sie zählen in der
|
||||
Rechenprobe mit, gelten aber nicht als laufende Einnahme.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Absendeknopf>
|
||||
{buchung ? 'Änderungen speichern' : schnellerfassung ? 'Speichern und nächste' : 'Buchung erfassen'}
|
||||
</Absendeknopf>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { Check, Loader2, X } from 'lucide-react'
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ibanFormatieren, ibanPruefen } from '@/lib/finanzen/iban'
|
||||
|
||||
/**
|
||||
* Eingabefelder, die im Finanzmodul mehrfach vorkommen.
|
||||
*
|
||||
* Beide sind bewusst nachsichtig: Sie korrigieren nichts hinter dem
|
||||
* Ruecken des Nutzers, sondern zeigen sofort, ob die Eingabe verstanden
|
||||
* wurde. Die eigentliche Pruefung passiert trotzdem noch einmal auf dem
|
||||
* Server - das Formular im Browser ist nur Bequemlichkeit.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Betragsfeld. inputMode="decimal" oeffnet am Handy die Zifferntastatur
|
||||
* mit Komma; type="text" statt type="number", weil ein Zahlenfeld in
|
||||
* deutschen Browsern beim Komma Aerger macht und Mausraeder Betraege
|
||||
* verstellen koennen.
|
||||
*/
|
||||
export function Betragseingabe({
|
||||
id,
|
||||
name,
|
||||
standardwert,
|
||||
pflicht,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
standardwert?: string
|
||||
pflicht?: boolean
|
||||
className?: string
|
||||
} & Omit<React.ComponentProps<'input'>, 'type' | 'defaultValue' | 'name' | 'id'>) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...rest}
|
||||
id={id}
|
||||
name={name}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
placeholder="1.234,56"
|
||||
defaultValue={standardwert}
|
||||
required={pflicht}
|
||||
className={className}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
>
|
||||
€
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* IBAN-Feld mit Vierergruppen und sofortiger Pruefziffernkontrolle.
|
||||
* Die Rueckmeldung steht als Text daneben, nicht nur als Farbe.
|
||||
*/
|
||||
export function Ibaneingabe({
|
||||
id,
|
||||
name,
|
||||
standardwert,
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
standardwert?: string | null
|
||||
}) {
|
||||
const [wert, setWert] = React.useState(ibanFormatieren(standardwert ?? ''))
|
||||
|
||||
const pruefung = React.useMemo(() => {
|
||||
if (wert.trim() === '') return null
|
||||
return ibanPruefen(wert)
|
||||
}, [wert])
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Input
|
||||
id={id}
|
||||
name={name}
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="DE89 3704 0044 0532 0130 00"
|
||||
value={wert}
|
||||
onChange={(ereignis) => setWert(ibanFormatieren(ereignis.target.value))}
|
||||
aria-invalid={pruefung && !pruefung.gueltig ? true : undefined}
|
||||
/>
|
||||
{pruefung && (
|
||||
<p
|
||||
className={
|
||||
pruefung.gueltig
|
||||
? 'flex items-center gap-1.5 text-sm text-ampel-gruen'
|
||||
: 'flex items-start gap-1.5 text-sm text-destructive'
|
||||
}
|
||||
>
|
||||
{pruefung.gueltig ? (
|
||||
<>
|
||||
<Check className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
Prüfziffer stimmt ({pruefung.land}).
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
{pruefung.meldung}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Schaltfläche, die während des Absendens sperrt und das sagt. */
|
||||
export function Absendeknopf({
|
||||
children,
|
||||
wartetext = 'Wird gespeichert …',
|
||||
...rest
|
||||
}: ButtonProps & { wartetext?: string }) {
|
||||
const status = useFormStatus()
|
||||
return (
|
||||
<Button {...rest} type="submit" disabled={status.pending || rest.disabled}>
|
||||
{status.pending && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
||||
{status.pending ? wartetext : children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Absendeknopf } from '@/components/finanzen/eingaben'
|
||||
import { importAusfuehren, type Formularzustand } from '@/server/actions/finanzen'
|
||||
|
||||
/**
|
||||
* Kontoumsätze aus einer Datei einlesen.
|
||||
*
|
||||
* Abtippen ist die Stelle, an der in der Rechnungslegung die Fehler
|
||||
* entstehen. Deshalb dieser Weg – und deshalb der Dublettenschutz:
|
||||
* Dieselbe Datei zweimal einzulesen legt nichts doppelt an.
|
||||
*/
|
||||
export function Importformular({
|
||||
betreuteId,
|
||||
konten,
|
||||
}: {
|
||||
betreuteId: string
|
||||
konten: { id: string; bezeichnung: string; istBetreuerkonto: boolean }[]
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(importAusfuehren, {})
|
||||
|
||||
return (
|
||||
<form action={absenden} className="space-y-5">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Das hat nicht geklappt</AlertTitle>
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{zustand.erfolg && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertTitle>{zustand.erfolg}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5">
|
||||
{(zustand.meldung ?? []).map((zeile, i) => (
|
||||
<li key={i}>{zeile}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!zustand.erfolg && zustand.meldung && zustand.meldung.length > 0 && (
|
||||
<Alert variant="warnung">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Diese Zeilen waren nicht lesbar</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5">
|
||||
{zustand.meldung.map((zeile, i) => (
|
||||
<li key={i}>{zeile}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Feld
|
||||
id="kontoId"
|
||||
beschriftung="Konto"
|
||||
pflicht
|
||||
erklaerung="Zu welchem Konto gehören die Umsätze in der Datei?"
|
||||
fehler={zustand.felder?.kontoId}
|
||||
>
|
||||
<Select name="kontoId" defaultValue={konten[0]?.id ?? ''}>
|
||||
{konten.map((konto) => (
|
||||
<option key={konto.id} value={konto.id}>
|
||||
{konto.bezeichnung}
|
||||
{konto.istBetreuerkonto ? ' (Konto des Betreuers)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="datei"
|
||||
beschriftung="Datei"
|
||||
pflicht
|
||||
erklaerung="CSV aus dem Online-Banking oder der elektronische Kontoauszug als CAMT.053-XML. Deutsche Zahlen (1.234,56), Semikolon oder Komma als Trennzeichen und Windows-Umlaute werden erkannt. Höchstens 8 MB."
|
||||
fehler={zustand.felder?.datei}
|
||||
>
|
||||
<Input
|
||||
type="file"
|
||||
name="datei"
|
||||
accept=".csv,.txt,.xml,text/csv,text/plain,application/xml,text/xml"
|
||||
required
|
||||
className="py-2"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox name="betrifftBetreute" defaultChecked className="mt-0.5" />
|
||||
<span className="space-y-1">
|
||||
<span className="block font-medium">
|
||||
Die Umsätze betreffen das Vermögen der betreuten Person
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Abwählen, wenn du ein eigenes Konto einliest, über das nur deine Auslagen laufen.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox name="dublettenUebernehmen" className="mt-0.5" />
|
||||
<span className="space-y-1">
|
||||
<span className="block font-medium">Auch bereits vorhandene Umsätze anlegen</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Normalerweise nicht anhaken. Nur sinnvoll, wenn du weißt, dass es tatsächlich zwei
|
||||
gleiche Buchungen gibt und der Dublettenschutz zu streng war.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Alert variant="hinweis">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Kategorien werden nicht geraten</AlertTitle>
|
||||
<AlertDescription>
|
||||
Importierte Buchungen landen zunächst in „Sonstige Einnahmen“ bzw. „Sonstige Ausgaben“.
|
||||
Eine automatisch geratene Kategorie wäre in der Rechnungslegung schlimmer als gar keine
|
||||
– ordne sie danach in der Buchungsliste zu.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Absendeknopf wartetext="Datei wird gelesen …">Datei einlesen</Absendeknopf>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Absendeknopf, Ibaneingabe } from '@/components/finanzen/eingaben'
|
||||
import {
|
||||
KONTO_ARTEN,
|
||||
KONTO_ART_BESCHRIFTUNG,
|
||||
KONTO_ART_ERKLAERUNG,
|
||||
KONTO_ARTEN_OHNE_IBAN,
|
||||
} from '@/lib/finanzen/kategorien'
|
||||
import { kontoSpeichern, type Formularzustand } from '@/server/actions/finanzen'
|
||||
import type { KontoArt } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Konto anlegen oder ändern.
|
||||
*
|
||||
* Die IBAN wird verschlüsselt gespeichert und zusätzlich als Blindindex
|
||||
* abgelegt. Deshalb kann die Anwendung „gibt es dieses Konto schon?“
|
||||
* beantworten, ohne die IBAN im Klartext in der Datenbank zu halten.
|
||||
*/
|
||||
export function Kontoformular({
|
||||
betreuteId,
|
||||
konto,
|
||||
}: {
|
||||
betreuteId: string
|
||||
konto?: {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
bank: string | null
|
||||
art: KontoArt
|
||||
iban: string | null
|
||||
bic: string | null
|
||||
istBetreuerkonto: boolean
|
||||
eroeffnetAm: string | null
|
||||
geschlossenAm: string | null
|
||||
}
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(kontoSpeichern, {})
|
||||
const [art, setArt] = React.useState<KontoArt>(konto?.art ?? 'GIRO')
|
||||
const [istBetreuerkonto, setIstBetreuerkonto] = React.useState(konto?.istBetreuerkonto ?? false)
|
||||
|
||||
const ibanNoetig = !KONTO_ARTEN_OHNE_IBAN.includes(art)
|
||||
|
||||
return (
|
||||
<form action={absenden} className="space-y-6">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
{konto && <input type="hidden" name="kontoId" value={konto.id} />}
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Das hat nicht geklappt</AlertTitle>
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="bezeichnung"
|
||||
beschriftung="Bezeichnung"
|
||||
pflicht
|
||||
erklaerung="Wie du das Konto im Alltag nennst, z.B. „Girokonto Sparkasse“."
|
||||
fehler={zustand.felder?.bezeichnung}
|
||||
>
|
||||
<Input name="bezeichnung" defaultValue={konto?.bezeichnung} required maxLength={120} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="bank"
|
||||
beschriftung="Bank oder Einrichtung"
|
||||
erklaerung="Bei einem Verwahrgeldkonto der Name des Heims."
|
||||
fehler={zustand.felder?.bank}
|
||||
>
|
||||
<Input name="bank" defaultValue={konto?.bank ?? ''} maxLength={120} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="art"
|
||||
beschriftung="Kontoart"
|
||||
pflicht
|
||||
erklaerung={KONTO_ART_ERKLAERUNG[art]}
|
||||
fehler={zustand.felder?.art}
|
||||
>
|
||||
<Select
|
||||
name="art"
|
||||
value={art}
|
||||
onChange={(ereignis) => setArt(ereignis.target.value as KontoArt)}
|
||||
>
|
||||
{KONTO_ARTEN.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{KONTO_ART_BESCHRIFTUNG[wert]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="bic"
|
||||
beschriftung="BIC"
|
||||
erklaerung="Optional. Acht oder elf Stellen."
|
||||
fehler={zustand.felder?.bic}
|
||||
>
|
||||
<Input name="bic" defaultValue={konto?.bic ?? ''} maxLength={11} autoComplete="off" />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
id="iban"
|
||||
beschriftung="IBAN"
|
||||
erklaerung={
|
||||
ibanNoetig
|
||||
? 'Wird verschlüsselt gespeichert. Die Prüfziffer wird sofort nachgerechnet.'
|
||||
: 'Für Barkasse und Verwahrgeld im Heim gibt es in der Regel keine IBAN – das Feld darf leer bleiben.'
|
||||
}
|
||||
fehler={zustand.felder?.iban}
|
||||
>
|
||||
<Ibaneingabe id="iban" name="iban" standardwert={konto?.iban ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="eroeffnetAm"
|
||||
beschriftung="Eröffnet am"
|
||||
erklaerung="Optional. Hilft, alte Kontoauszüge einzuordnen."
|
||||
fehler={zustand.felder?.eroeffnetAm}
|
||||
>
|
||||
<Input type="date" name="eroeffnetAm" defaultValue={konto?.eroeffnetAm ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="geschlossenAm"
|
||||
beschriftung="Geschlossen am"
|
||||
erklaerung="Ein geschlossenes Konto bleibt stehen – die Rechnungslegung braucht es weiter."
|
||||
fehler={zustand.felder?.geschlossenAm}
|
||||
>
|
||||
<Input type="date" name="geschlossenAm" defaultValue={konto?.geschlossenAm ?? ''} />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border p-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
name="istBetreuerkonto"
|
||||
defaultChecked={konto?.istBetreuerkonto}
|
||||
onChange={(ereignis) => setIstBetreuerkonto(ereignis.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block font-medium">Das ist ein Konto des Betreuers</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Also dein eigenes Konto, nicht das der betreuten Person. Buchungen darauf gehören
|
||||
nicht in das Vermögen der Betreuten und tauchen in der Rechnungslegung nur als
|
||||
eigene Auslage auf.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{istBetreuerkonto && (
|
||||
<Alert variant="warnung">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Zwei Vermögen, zwei Konten</AlertTitle>
|
||||
<AlertDescription>
|
||||
Von diesem Konto darf kein Geld der betreuten Person verwaltet werden. Wenn du hier
|
||||
Buchungen erfasst, die das Vermögen der Betreuten betreffen, warnt dich die
|
||||
Anwendung – das ist der Fehler, der in der Rechnungslegung Ärger macht.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Absendeknopf>{konto ? 'Änderungen speichern' : 'Konto anlegen'}</Absendeknopf>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
|
||||
import {
|
||||
SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG,
|
||||
VERMOEGENS_ARTEN,
|
||||
VERMOEGENS_ART_EINZAHL,
|
||||
} from '@/lib/finanzen/kategorien'
|
||||
import {
|
||||
vermoegenspostenSpeichern,
|
||||
wertstandAnlegen,
|
||||
type Formularzustand,
|
||||
} from '@/server/actions/finanzen'
|
||||
import type { SozialhilfeBewertung, VermoegensArt } from '@prisma/client'
|
||||
|
||||
const BEWERTUNGEN: SozialhilfeBewertung[] = [
|
||||
'UNGEPRUEFT',
|
||||
'VERWERTBAR',
|
||||
'GESCHUETZT',
|
||||
'STRITTIG',
|
||||
]
|
||||
|
||||
/**
|
||||
* Vermögensposten anlegen oder ändern.
|
||||
*
|
||||
* Der Posten selbst trägt keinen Betrag – der steckt in den
|
||||
* Wertständen. Ein Posten ist die Sache („Hausgrundstück
|
||||
* Bayreuther Str. 4“), ein Wertstand ist eine Aussage über ihren Wert an
|
||||
* einem bestimmten Tag mit einer bestimmten Quelle.
|
||||
*/
|
||||
export function Postenformular({
|
||||
betreuteId,
|
||||
konten,
|
||||
posten,
|
||||
}: {
|
||||
betreuteId: string
|
||||
konten: { id: string; bezeichnung: string }[]
|
||||
posten?: {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
art: VermoegensArt
|
||||
kontoId: string | null
|
||||
bewertungSozialhilfe: SozialhilfeBewertung
|
||||
bewertungBegruendung: string | null
|
||||
notizen: string | null
|
||||
}
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
|
||||
vermoegenspostenSpeichern,
|
||||
{},
|
||||
)
|
||||
const [art, setArt] = React.useState<VermoegensArt>(posten?.art ?? 'VERSICHERUNG')
|
||||
|
||||
return (
|
||||
<form action={absenden} className="space-y-6">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
{posten && <input type="hidden" name="postenId" value={posten.id} />}
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Das hat nicht geklappt</AlertTitle>
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="bezeichnung"
|
||||
beschriftung="Bezeichnung"
|
||||
pflicht
|
||||
erklaerung="So genau, dass der Posten im Verzeichnis eindeutig ist."
|
||||
fehler={zustand.felder?.bezeichnung}
|
||||
>
|
||||
<Input name="bezeichnung" defaultValue={posten?.bezeichnung} required maxLength={160} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="art"
|
||||
beschriftung="Art"
|
||||
pflicht
|
||||
erklaerung={
|
||||
art === 'VERBINDLICHKEIT'
|
||||
? 'Verbindlichkeiten werden mit positivem Betrag geführt – dass sie das Vermögen mindern, ergibt sich aus der Art.'
|
||||
: 'Bestimmt, unter welcher Überschrift der Posten im Vermögensverzeichnis steht.'
|
||||
}
|
||||
fehler={zustand.felder?.art}
|
||||
>
|
||||
<Select
|
||||
name="art"
|
||||
value={art}
|
||||
onChange={(ereignis) => setArt(ereignis.target.value as VermoegensArt)}
|
||||
>
|
||||
{VERMOEGENS_ARTEN.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{VERMOEGENS_ART_EINZAHL[wert]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
{art === 'KONTO' && (
|
||||
<>
|
||||
<Feld
|
||||
id="kontoId"
|
||||
beschriftung="Zugehöriges Konto"
|
||||
erklaerung="Verknüpft den Posten mit einem erfassten Konto."
|
||||
fehler={zustand.felder?.kontoId}
|
||||
>
|
||||
<Select name="kontoId" defaultValue={posten?.kontoId ?? ''}>
|
||||
<option value="">– keines –</option>
|
||||
{konten.map((konto) => (
|
||||
<option key={konto.id} value={konto.id}>
|
||||
{konto.bezeichnung}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
<Alert variant="hinweis">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Konten stehen schon im Verzeichnis</AlertTitle>
|
||||
<AlertDescription>
|
||||
Konten kommen mit ihrem Saldostand ins Vermögensverzeichnis. Ein zusätzlicher Posten
|
||||
der Art „Konto“ wird dort deshalb <strong>nicht</strong> mitgezählt – sonst stünde
|
||||
dasselbe Guthaben zweimal in der Summe. Für Depots, deren Wert du gesondert
|
||||
belegen willst, ist der Posten trotzdem sinnvoll.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Feld
|
||||
id="bewertungSozialhilfe"
|
||||
beschriftung="Einordnung im Sozialhilfeverfahren"
|
||||
erklaerung="Deine eigene Einschätzung und Dokumentation gegenüber dem Bezirk – keine Rechtsauskunft."
|
||||
fehler={zustand.felder?.bewertungSozialhilfe}
|
||||
>
|
||||
<Select
|
||||
name="bewertungSozialhilfe"
|
||||
defaultValue={posten?.bewertungSozialhilfe ?? 'UNGEPRUEFT'}
|
||||
>
|
||||
{BEWERTUNGEN.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG[wert]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="bewertungBegruendung"
|
||||
beschriftung="Begründung der Einordnung"
|
||||
erklaerung="Warum hältst du den Posten für geschützt oder verwertbar? Verweis auf § 90 SGB XII, Schonvermögen, Bestattungsvorsorge, selbst bewohntes Hausgrundstück. Wird verschlüsselt gespeichert."
|
||||
fehler={zustand.felder?.bewertungBegruendung}
|
||||
>
|
||||
<Textarea
|
||||
name="bewertungBegruendung"
|
||||
defaultValue={posten?.bewertungBegruendung ?? ''}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="notizen"
|
||||
beschriftung="Notizen"
|
||||
erklaerung="Wird verschlüsselt gespeichert."
|
||||
fehler={zustand.felder?.notizen}
|
||||
>
|
||||
<Textarea name="notizen" defaultValue={posten?.notizen ?? ''} rows={3} maxLength={1000} />
|
||||
</Feld>
|
||||
|
||||
<Absendeknopf>{posten ? 'Änderungen speichern' : 'Posten anlegen'}</Absendeknopf>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertstand erfassen.
|
||||
*
|
||||
* Die Wertquelle ist Pflichtfeld – und zwar nicht aus Formalismus: Eine
|
||||
* Zahl im Vermögensverzeichnis, bei der sich nach zwei Jahren nicht mehr
|
||||
* klären lässt, woher sie stammt, ist wertlos. Steht dagegen
|
||||
* „Rückkaufswertmitteilung vom 12.03.2026“ daneben, ist die Rückfrage
|
||||
* beantwortet, bevor sie gestellt wird.
|
||||
*/
|
||||
export function Wertstandformular({
|
||||
betreuteId,
|
||||
vermoegenspostenId,
|
||||
dokumente,
|
||||
standardStichtag,
|
||||
}: {
|
||||
betreuteId: string
|
||||
vermoegenspostenId: string
|
||||
dokumente: { id: string; titel: string }[]
|
||||
standardStichtag?: string
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(wertstandAnlegen, {})
|
||||
|
||||
return (
|
||||
<form action={absenden} className="space-y-4">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="vermoegenspostenId" value={vermoegenspostenId} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Das hat nicht geklappt</AlertTitle>
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{zustand.erfolg && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertTitle>Gespeichert</AlertTitle>
|
||||
<AlertDescription>{zustand.erfolg}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="stichtag"
|
||||
beschriftung="Stichtag"
|
||||
pflicht
|
||||
erklaerung="Auf welchen Tag bezieht sich der Wert?"
|
||||
fehler={zustand.felder?.stichtag}
|
||||
>
|
||||
<Input type="date" name="stichtag" defaultValue={standardStichtag} required />
|
||||
</Feld>
|
||||
|
||||
<Feld id="wert" beschriftung="Wert" pflicht fehler={zustand.felder?.wert}>
|
||||
<Betragseingabe id="wert" name="wert" pflicht />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
id="wertquelle"
|
||||
beschriftung="Wertquelle"
|
||||
pflicht
|
||||
erklaerung="Woher stammt der Wert? „Rückkaufswertmitteilung vom 12.03.2026“, „Maklergutachten“, „Bodenrichtwert“, „eigene Schätzung“."
|
||||
fehler={zustand.felder?.wertquelle}
|
||||
>
|
||||
<Input
|
||||
name="wertquelle"
|
||||
required
|
||||
maxLength={200}
|
||||
placeholder="Rückkaufswertmitteilung vom 12.03.2026"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="nachweisDokumentId"
|
||||
beschriftung="Nachweisdokument"
|
||||
erklaerung="Optional, aber hilfreich: das Schreiben, aus dem der Wert stammt."
|
||||
fehler={zustand.felder?.nachweisDokumentId}
|
||||
>
|
||||
<Select name="nachweisDokumentId" defaultValue="">
|
||||
<option value="">– kein Nachweis hinterlegt –</option>
|
||||
{dokumente.map((dokument) => (
|
||||
<option key={dokument.id} value={dokument.id}>
|
||||
{dokument.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
|
||||
<Input name="bemerkung" maxLength={500} />
|
||||
</Feld>
|
||||
|
||||
<Absendeknopf>Wert festhalten</Absendeknopf>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Absendeknopf, Betragseingabe } from '@/components/finanzen/eingaben'
|
||||
import { saldostandAnlegen, type Formularzustand } from '@/server/actions/finanzen'
|
||||
|
||||
/**
|
||||
* Kontostand zu einem Stichtag festhalten.
|
||||
*
|
||||
* Ein Saldostand wird nie überschrieben. Jeder Eintrag bleibt stehen,
|
||||
* damit sich eine einmal abgegebene Vermögensübersicht Jahre später
|
||||
* exakt wieder herstellen lässt – auch dann, wenn inzwischen ein
|
||||
* Kontoauszug korrigiert wurde.
|
||||
*/
|
||||
export function Saldoformular({
|
||||
betreuteId,
|
||||
konten,
|
||||
kontoId,
|
||||
standardStichtag,
|
||||
}: {
|
||||
betreuteId: string
|
||||
konten: { id: string; bezeichnung: string }[]
|
||||
kontoId?: string
|
||||
standardStichtag?: string
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(saldostandAnlegen, {})
|
||||
|
||||
return (
|
||||
<form action={absenden} className="space-y-4">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertTitle>Das hat nicht geklappt</AlertTitle>
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{zustand.erfolg && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertTitle>Gespeichert</AlertTitle>
|
||||
<AlertDescription>{zustand.erfolg}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{kontoId ? (
|
||||
<input type="hidden" name="kontoId" value={kontoId} />
|
||||
) : (
|
||||
<Feld id="kontoId" beschriftung="Konto" pflicht fehler={zustand.felder?.kontoId}>
|
||||
<Select name="kontoId" defaultValue={konten[0]?.id ?? ''}>
|
||||
{konten.map((konto) => (
|
||||
<option key={konto.id} value={konto.id}>
|
||||
{konto.bezeichnung}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="stichtag"
|
||||
beschriftung="Stichtag"
|
||||
pflicht
|
||||
erklaerung="Der Stand gilt als Bestand am Ende dieses Tages."
|
||||
fehler={zustand.felder?.stichtag}
|
||||
>
|
||||
<Input type="date" name="stichtag" defaultValue={standardStichtag} required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="betrag"
|
||||
beschriftung="Kontostand"
|
||||
pflicht
|
||||
erklaerung="Ein überzogenes Konto wird mit Minus eingetragen."
|
||||
fehler={zustand.felder?.betrag}
|
||||
>
|
||||
<Betragseingabe id="betrag" name="betrag" pflicht />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
id="quelle"
|
||||
beschriftung="Woher stammt der Stand?"
|
||||
erklaerung="z.B. „Kontoauszug Nr. 3“ oder „Online-Banking, abgerufen am 15.03.“. Das beantwortet die Rückfrage des Rechtspflegers vorab."
|
||||
fehler={zustand.felder?.quelle}
|
||||
>
|
||||
<Input name="quelle" maxLength={160} placeholder="Kontoauszug Nr. 3" />
|
||||
</Feld>
|
||||
|
||||
<Absendeknopf>Kontostand festhalten</Absendeknopf>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import * as React from 'react'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { formatCent, formatDatum } from '@/lib/utils'
|
||||
import { Betrag } from '@/components/finanzen/bausteine'
|
||||
|
||||
export type Verlaufszeile = {
|
||||
id: string
|
||||
/** ISO-Datum, damit der Wert auch an Client-Komponenten weitergereicht werden kann. */
|
||||
stichtag: string
|
||||
cent: number
|
||||
wertquelle: string
|
||||
nachweisTitel: string | null
|
||||
bemerkung: string | null
|
||||
veraenderungCent: number | null
|
||||
/** Aktion am Zeilenende, z.B. ein Löschknopf. */
|
||||
aktion?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertverlauf eines Vermögenspostens: Tabelle plus eine kleine Kurve.
|
||||
*
|
||||
* Die Kurve ist bewusst handgezeichnetes SVG und kein Diagrammpaket.
|
||||
* Sie soll eine einzige Frage beantworten – „geht es rauf oder runter?“ –
|
||||
* und dabei nichts nachladen, sauber ausdrucken und in beiden
|
||||
* Farbschemata lesbar bleiben. Die genauen Zahlen stehen darunter in
|
||||
* der Tabelle; die Kurve ersetzt sie nicht.
|
||||
*/
|
||||
export function Wertverlauf({ zeilen }: { zeilen: Verlaufszeile[] }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Verlaufskurve zeilen={zeilen} />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stichtag</TableHead>
|
||||
<TableHead>Wert</TableHead>
|
||||
<TableHead>Veränderung</TableHead>
|
||||
<TableHead>Wertquelle</TableHead>
|
||||
<TableHead>Nachweis</TableHead>
|
||||
{zeilen.some((z) => z.aktion) && <TableHead className="text-right">Aktion</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...zeilen]
|
||||
.sort((a, b) => b.stichtag.localeCompare(a.stichtag))
|
||||
.map((zeile) => (
|
||||
<TableRow key={zeile.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDatum(new Date(zeile.stichtag))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Betrag cent={zeile.cent} fett />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{zeile.veraenderungCent === null ? (
|
||||
<span className="text-muted-foreground">erster Wert</span>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
zeile.veraenderungCent < 0
|
||||
? 'tabular-nums text-ampel-rot'
|
||||
: zeile.veraenderungCent > 0
|
||||
? 'tabular-nums text-ampel-gruen'
|
||||
: 'tabular-nums text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{zeile.veraenderungCent > 0 ? '+' : ''}
|
||||
{formatCent(zeile.veraenderungCent)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="block">{zeile.wertquelle}</span>
|
||||
{zeile.bemerkung && (
|
||||
<span className="block text-sm text-muted-foreground">{zeile.bemerkung}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{zeile.nachweisTitel ?? 'kein Dokument hinterlegt'}
|
||||
</TableCell>
|
||||
{zeilen.some((z) => z.aktion) && (
|
||||
<TableCell className="text-right">{zeile.aktion}</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BREITE = 640
|
||||
const HOEHE = 120
|
||||
const RAND = 8
|
||||
|
||||
/** Normiert die Werte auf das Zeichenfeld und zeichnet eine Polylinie. */
|
||||
export function Verlaufskurve({ zeilen }: { zeilen: Verlaufszeile[] }) {
|
||||
if (zeilen.length < 2) {
|
||||
return (
|
||||
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
Sobald ein zweiter Wertstand erfasst ist, siehst du hier den Verlauf.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const sortiert = [...zeilen].sort((a, b) => a.stichtag.localeCompare(b.stichtag))
|
||||
const zeiten = sortiert.map((z) => new Date(z.stichtag).getTime())
|
||||
const werte = sortiert.map((z) => z.cent)
|
||||
|
||||
const tMin = Math.min(...zeiten)
|
||||
const tMax = Math.max(...zeiten)
|
||||
const wMin = Math.min(...werte)
|
||||
const wMax = Math.max(...werte)
|
||||
const tSpanne = tMax - tMin || 1
|
||||
const wSpanne = wMax - wMin || 1
|
||||
|
||||
const punkte = sortiert.map((zeile, i) => ({
|
||||
x: RAND + ((zeiten[i]! - tMin) / tSpanne) * (BREITE - 2 * RAND),
|
||||
y: HOEHE - RAND - ((werte[i]! - wMin) / wSpanne) * (HOEHE - 2 * RAND),
|
||||
zeile,
|
||||
}))
|
||||
|
||||
const linie = punkte.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
|
||||
const flaeche = `${RAND},${HOEHE - RAND} ${linie} ${(BREITE - RAND).toFixed(1)},${HOEHE - RAND}`
|
||||
|
||||
const beschreibung =
|
||||
`Wertverlauf von ${formatCent(werte[0]!)} am ${formatDatum(new Date(sortiert[0]!.stichtag))} ` +
|
||||
`bis ${formatCent(werte[werte.length - 1]!)} am ` +
|
||||
`${formatDatum(new Date(sortiert[sortiert.length - 1]!.stichtag))}.`
|
||||
|
||||
return (
|
||||
<figure className="space-y-1">
|
||||
<div className="overflow-x-auto rounded-md border bg-card p-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${BREITE} ${HOEHE}`}
|
||||
className="h-28 w-full min-w-[20rem]"
|
||||
role="img"
|
||||
aria-label={beschreibung}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<polygon points={flaeche} className="fill-primary/10" />
|
||||
<polyline
|
||||
points={linie}
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{punkte.map((punkt) => (
|
||||
<circle
|
||||
key={punkt.zeile.id}
|
||||
cx={punkt.x}
|
||||
cy={punkt.y}
|
||||
r={3}
|
||||
className="fill-primary"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<figcaption className="flex flex-wrap justify-between gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{formatDatum(new Date(sortiert[0]!.stichtag))} · {formatCent(wMin)} (niedrigster Wert)
|
||||
</span>
|
||||
<span>
|
||||
{formatDatum(new Date(sortiert[sortiert.length - 1]!.stichtag))} · {formatCent(wMax)}{' '}
|
||||
(höchster Wert)
|
||||
</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Link from 'next/link'
|
||||
import { HinweisKarte } from '@/components/hinweise/hinweis-karte'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { hinweiseFuerKontext } from '@/lib/hinweise/laden'
|
||||
|
||||
/**
|
||||
* Zeigt die Hinweiskarten, die zum Kontext einer Seite passen.
|
||||
*
|
||||
* Die Karten kommen aus daten/hinweise.json und sind kein Beiwerk: Sie
|
||||
* stehen an der Stelle, an der ein Fehler teuer wuerde - die Warnung vor
|
||||
* der Genehmigungspflicht steht neben der Wohnungsaufloesung, nicht in
|
||||
* einem Handbuch. Zu jeder Karte gehoert der stehende Rechtshinweis: Das
|
||||
* hier ist eine Gedaechtnisstuetze, keine Rechtsberatung.
|
||||
*
|
||||
* Kontexte sind Modulnamen ("IMMOBILIE"), "VORGANG:<Kategorie>",
|
||||
* "VORLAGE:<schluessel>" oder "FRIST:<systemSchluessel>". Ein Ausloeser
|
||||
* ohne Jahreszahl passt auch auf die Frist mit Jahreszahl, damit
|
||||
* "FRIST:jahresbericht" nicht jaehrlich nachgepflegt werden muss.
|
||||
*
|
||||
* Serverkomponente - sie liest die Wissensbasis von der Platte.
|
||||
*/
|
||||
export function HinweisKarten({
|
||||
kontext,
|
||||
grenze = 3,
|
||||
ueberschrift = 'Darauf solltest du achten',
|
||||
className,
|
||||
}: {
|
||||
kontext: readonly string[]
|
||||
/** Wie viele Karten hoechstens? Mehr als drei liest niemand. */
|
||||
grenze?: number
|
||||
ueberschrift?: string
|
||||
className?: string
|
||||
}) {
|
||||
const karten = hinweiseFuerKontext(kontext, grenze)
|
||||
if (karten.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className={cn('space-y-3', className)} aria-label={ueberschrift}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">{ueberschrift}</h2>
|
||||
<Link
|
||||
href="/hinweise"
|
||||
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Alle Hinweise
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{karten.map((karte) => (
|
||||
<HinweisKarte key={karte.schluessel} karte={karte} klein />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { HinweisKarte }
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink, Lightbulb } from 'lucide-react'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Hinweiskarte } from '@/lib/hinweise/laden'
|
||||
|
||||
/**
|
||||
* Die Darstellung einer einzelnen Hinweiskarte.
|
||||
*
|
||||
* Bewusst in einer eigenen Datei und ohne Zugriff auf die Wissensbasis:
|
||||
* Diese Komponente wird auch aus der Suche im Browser heraus benutzt, und
|
||||
* das Laden der JSON-Datei geht ueber "node:fs" - das darf nie im
|
||||
* Browserbuendel landen. Deshalb hier nur der Typ, nie der Lader.
|
||||
*/
|
||||
export function HinweisKarte({ karte, klein = false }: { karte: Hinweiskarte; klein?: boolean }) {
|
||||
return (
|
||||
<Card className={cn(klein && 'shadow-none')}>
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
|
||||
<Lightbulb className="mt-0.5 size-5 shrink-0 text-ampel-gelb" aria-hidden="true" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className={cn('leading-snug', klein ? 'text-base' : 'text-lg')}>
|
||||
{karte.titel}
|
||||
</CardTitle>
|
||||
<Badge variant="outline">{karte.rechtsgrundlage}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm leading-relaxed">{karte.text}</p>
|
||||
<Link
|
||||
href={karte.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{karte.rechtsgrundlage} im Gesetzestext nachlesen
|
||||
<ExternalLink className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Rechtshinweis rechtsgrundlage={karte.rechtsgrundlage} kurz />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { HinweisKarte } from '@/components/hinweise/hinweis-karte'
|
||||
import type { Hinweiskarte } from '@/lib/hinweise/laden'
|
||||
|
||||
/**
|
||||
* Durchsuchbare Liste aller Hinweiskarten.
|
||||
*
|
||||
* Die Karten kommen fertig geladen von der Serverseite - gesucht wird im
|
||||
* Browser, weil es nur ein paar Dutzend sind und jede Runde zum Server
|
||||
* sich beim Tippen wie Warten anfuehlen wuerde. Die Suchlogik steht hier
|
||||
* noch einmal, statt aus lib/hinweise/laden.ts importiert zu werden: Jenes
|
||||
* Modul liest die Datei mit "node:fs" und gehoert nicht ins Browserbuendel.
|
||||
*/
|
||||
function suchen(karten: Hinweiskarte[], suche: string): Hinweiskarte[] {
|
||||
const sortiert = [...karten].sort(
|
||||
(a, b) => b.gewicht - a.gewicht || a.titel.localeCompare(b.titel, 'de'),
|
||||
)
|
||||
const begriff = suche.trim().toLowerCase()
|
||||
if (begriff === '') return sortiert
|
||||
|
||||
return sortiert.filter((karte) =>
|
||||
[karte.titel, karte.text, karte.rechtsgrundlage, karte.schluessel, ...karte.kontext]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(begriff),
|
||||
)
|
||||
}
|
||||
|
||||
export function HinweisSuche({ karten }: { karten: Hinweiskarte[] }) {
|
||||
const [suche, setSuche] = React.useState('')
|
||||
const treffer = React.useMemo(() => suchen(karten, suche), [karten, suche])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Feld
|
||||
id="hinweis-suche"
|
||||
beschriftung="Suchen"
|
||||
erklaerung="Sucht in Überschrift, Text, Rechtsgrundlage und Auslöser – zum Beispiel „Genehmigung“, „1878“ oder „Heim“."
|
||||
>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 opacity-60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={suche}
|
||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||
placeholder="Stichwort oder Paragraph"
|
||||
className="pl-9"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</Feld>
|
||||
|
||||
<p className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{treffer.length === karten.length
|
||||
? `${karten.length} Hinweise`
|
||||
: `${treffer.length} von ${karten.length} Hinweisen`}
|
||||
</p>
|
||||
|
||||
{treffer.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Dazu ist nichts hinterlegt"
|
||||
beschreibung="Die Hinweise stehen in daten/hinweise.json. Fehlt dir etwas, das du immer wieder nachschlägst, trag es dort nach – die Datei wird beim nächsten Start eingelesen."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{treffer.map((karte) => (
|
||||
<HinweisKarte key={karte.schluessel} karte={karte} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { BookOpen, Plus, Trash2 } 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 { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
||||
import {
|
||||
BELASTUNG_ARTEN,
|
||||
BELASTUNG_ART_ABTEILUNG,
|
||||
GRUNDBUCH_ABTEILUNGEN,
|
||||
GRUNDBUCH_ABTEILUNG_ERKLAERUNG,
|
||||
} from '@/lib/immobilie/konstanten'
|
||||
import { belastungLoeschen, belastungSpeichern } from '@/server/actions/immobilie'
|
||||
import { formatCent, formatDatum } from '@/lib/utils'
|
||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
export type BelastungZeile = {
|
||||
id: string
|
||||
art: string
|
||||
abteilung: string | null
|
||||
laufendeNr: string | null
|
||||
glaeubiger: string | null
|
||||
betragCent: number | null
|
||||
bemerkung: string | null
|
||||
geloeschtImGrundbuchAm: string | null
|
||||
}
|
||||
|
||||
export function Belastungen({
|
||||
betreuteId,
|
||||
immobilieId,
|
||||
belastungen,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
betreuteId: string
|
||||
immobilieId: string
|
||||
belastungen: BelastungZeile[]
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(belastungSpeichern, {})
|
||||
const [formularOffen, setFormularOffen] = useState(false)
|
||||
const [art, setArt] = useState<string>('Grundschuld')
|
||||
|
||||
const vorschlag = BELASTUNG_ART_ABTEILUNG[art as keyof typeof BELASTUNG_ART_ABTEILUNG] ?? 'II'
|
||||
const aktive = belastungen.filter((b) => !b.geloeschtImGrundbuchAm)
|
||||
const summe = aktive
|
||||
.filter((b) => b.abteilung === 'III')
|
||||
.reduce((wert, b) => wert + (b.betragCent ?? 0), 0)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="size-5" aria-hidden="true" />
|
||||
Belastungen im Grundbuch
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Was in Abteilung II und III eingetragen ist. Beides mindert den Wert — auf ganz
|
||||
unterschiedliche Weise.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{GRUNDBUCH_ABTEILUNGEN.map((abteilung) => (
|
||||
<div key={abteilung} className="rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{GRUNDBUCH_ABTEILUNG_ERKLAERUNG[abteilung]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{belastungen.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch keine Belastungen erfasst"
|
||||
beschreibung="Nimm den Grundbuchauszug zur Hand und trage ein, was in Abteilung II und III steht. Ohne diese Zahlen lässt sich der Wert des Grundstücks nicht beziffern."
|
||||
icon={BookOpen}
|
||||
aktion={
|
||||
darfSchreiben && (
|
||||
<Button type="button" onClick={() => setFormularOffen(true)}>
|
||||
<Plus aria-hidden="true" />
|
||||
Belastung eintragen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Abt.</TableHead>
|
||||
<TableHead>lfd. Nr.</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Gläubiger / Berechtigter</TableHead>
|
||||
<TableHead className="text-right">Betrag</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
{darfSchreiben && <TableHead className="sr-only">Aktion</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{belastungen.map((belastung) => (
|
||||
<TableRow key={belastung.id}>
|
||||
<TableCell className="font-medium">{belastung.abteilung ?? '–'}</TableCell>
|
||||
<TableCell>{belastung.laufendeNr ?? '–'}</TableCell>
|
||||
<TableCell>{belastung.art}</TableCell>
|
||||
<TableCell>{belastung.glaeubiger ?? '–'}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCent(belastung.betragCent)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{belastung.geloeschtImGrundbuchAm ? (
|
||||
<Badge variant="gruen">
|
||||
gelöscht am {formatDatum(belastung.geloeschtImGrundbuchAm)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">eingetragen</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
{darfSchreiben && (
|
||||
<TableCell>
|
||||
<form
|
||||
action={async () => {
|
||||
await belastungLoeschen(betreuteId, immobilieId, belastung.id)
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${belastung.art} entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{summe > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nennbetrag der eingetragenen Grundpfandrechte (Abteilung III):{' '}
|
||||
<strong className="text-foreground">{formatCent(summe)}</strong>. Das ist die
|
||||
Höchstsumme aus dem Grundbuch, nicht die Restschuld — frag bei der Bank nach
|
||||
einer aktuellen Ablösesumme.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{darfSchreiben && (formularOffen || belastungen.length > 0) && (
|
||||
<div className="rounded-md border p-4">
|
||||
{!formularOffen ? (
|
||||
<Button type="button" variant="outline" onClick={() => setFormularOffen(true)}>
|
||||
<Plus aria-hidden="true" />
|
||||
Weitere Belastung eintragen
|
||||
</Button>
|
||||
) : (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="immobilieId" value={immobilieId} />
|
||||
|
||||
<Zustandsmeldung zustand={zustand} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="belastung-art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
|
||||
<Select name="art" value={art} onChange={(e) => setArt(e.target.value)}>
|
||||
{BELASTUNG_ARTEN.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="belastung-abteilung"
|
||||
beschriftung="Grundbuchabteilung"
|
||||
erklaerung={`Bei „${art}“ üblicherweise Abteilung ${vorschlag}.`}
|
||||
fehler={zustand.felder?.abteilung}
|
||||
>
|
||||
<Select name="abteilung" defaultValue={vorschlag} key={vorschlag}>
|
||||
{GRUNDBUCH_ABTEILUNGEN.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
Abteilung {a}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="belastung-laufendeNr"
|
||||
beschriftung="Laufende Nummer"
|
||||
erklaerung="Die Nummer der Eintragung in der jeweiligen Abteilung."
|
||||
fehler={zustand.felder?.laufendeNr}
|
||||
>
|
||||
<Input name="laufendeNr" maxLength={20} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="belastung-glaeubiger"
|
||||
beschriftung="Gläubiger / Berechtigter"
|
||||
fehler={zustand.felder?.glaeubiger}
|
||||
>
|
||||
<Input name="glaeubiger" maxLength={150} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="belastung-betrag"
|
||||
beschriftung="Betrag"
|
||||
erklaerung="Der eingetragene Nennbetrag, z. B. 50.000,00"
|
||||
fehler={zustand.felder?.betrag}
|
||||
>
|
||||
<Input name="betrag" inputMode="decimal" placeholder="50.000,00" />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="belastung-geloescht"
|
||||
beschriftung="Im Grundbuch gelöscht am"
|
||||
fehler={zustand.felder?.geloeschtImGrundbuchAm}
|
||||
>
|
||||
<Input name="geloeschtImGrundbuchAm" type="date" />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feld id="belastung-bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
|
||||
<Textarea name="bemerkung" rows={2} maxLength={1000} />
|
||||
</Feld>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Absendeknopf>Belastung speichern</Absendeknopf>
|
||||
<Button type="button" variant="ghost" onClick={() => setFormularOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { CalendarClock, ClipboardList, Plus, Repeat, Trash2, TriangleAlert } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Fortschritt } from '@/components/ui/fortschritt'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
||||
import {
|
||||
checklisteUebernehmen,
|
||||
checkpunktErledigen,
|
||||
checkpunktLoeschen,
|
||||
checkpunktSpeichern,
|
||||
} from '@/server/actions/immobilie'
|
||||
import { formatDatum, relativeTage } from '@/lib/utils'
|
||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
export type CheckpunktZeile = {
|
||||
id: string
|
||||
titel: string
|
||||
beschreibung: string | null
|
||||
erledigt: boolean
|
||||
erledigtAm: string | null
|
||||
faelligAm: string | null
|
||||
wiederholungRrule: string | null
|
||||
tageBisFaellig: number | null
|
||||
}
|
||||
|
||||
function ampel(punkt: CheckpunktZeile): { variante: 'rot' | 'orange' | 'gelb' | 'gruen'; text: string } {
|
||||
if (punkt.erledigt) return { variante: 'gruen', text: 'erledigt' }
|
||||
if (punkt.tageBisFaellig === null) return { variante: 'gelb', text: 'ohne Termin' }
|
||||
if (punkt.tageBisFaellig < 0) {
|
||||
return { variante: 'rot', text: `überfällig seit ${Math.abs(punkt.tageBisFaellig)} Tagen` }
|
||||
}
|
||||
if (punkt.tageBisFaellig <= 7) return { variante: 'orange', text: relativeTage(punkt.tageBisFaellig) }
|
||||
if (punkt.tageBisFaellig <= 31) return { variante: 'gelb', text: relativeTage(punkt.tageBisFaellig) }
|
||||
return { variante: 'gruen', text: relativeTage(punkt.tageBisFaellig) }
|
||||
}
|
||||
|
||||
export function Checkliste({
|
||||
betreuteId,
|
||||
immobilieId,
|
||||
punkte,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
betreuteId: string
|
||||
immobilieId: string
|
||||
punkte: CheckpunktZeile[]
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(checkpunktSpeichern, {})
|
||||
const [uebernahme, uebernahmeAktion] = useActionState<Formularzustand, FormData>(
|
||||
checklisteUebernehmen,
|
||||
{},
|
||||
)
|
||||
const [offen, setOffen] = useState(false)
|
||||
|
||||
const erledigt = punkte.filter((p) => p.erledigt).length
|
||||
const ueberfaellig = punkte.filter(
|
||||
(p) => !p.erledigt && p.tageBisFaellig !== null && p.tageBisFaellig < 0,
|
||||
)
|
||||
|
||||
if (punkte.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Zustandsmeldung zustand={uebernahme} />
|
||||
<Leerzustand
|
||||
titel="Noch keine Checkliste angelegt"
|
||||
beschreibung="Die vorbereitete Liste enthält zehn Punkte, die bei einem leerstehenden Haus regelmäßig teuer werden — vom Gebäudeversicherer bis zum Frostschutz. Jeder Punkt lässt sich danach ändern, verschieben oder löschen."
|
||||
icon={ClipboardList}
|
||||
aktion={
|
||||
darfSchreiben && (
|
||||
<form action={uebernahmeAktion}>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="immobilieId" value={immobilieId} />
|
||||
<Absendeknopf>Checkliste übernehmen</Absendeknopf>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Zustandsmeldung zustand={uebernahme} />
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-5">
|
||||
<Fortschritt
|
||||
wert={erledigt}
|
||||
gesamt={punkte.length}
|
||||
beschriftung="Punkte abgehakt"
|
||||
/>
|
||||
{ueberfaellig.length > 0 && (
|
||||
<p className="flex items-start gap-2 text-sm font-medium text-ampel-rot">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
{ueberfaellig.length === 1
|
||||
? '1 Punkt ist überfällig.'
|
||||
: `${ueberfaellig.length} Punkte sind überfällig.`}{' '}
|
||||
Bei Leerstand ist das kein Schönheitsfehler — der Versicherer fragt im
|
||||
Schadensfall danach.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{punkte.map((punkt) => {
|
||||
const lage = ampel(punkt)
|
||||
return (
|
||||
<li key={punkt.id}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{darfSchreiben && (
|
||||
<form
|
||||
action={async () => {
|
||||
await checkpunktErledigen(
|
||||
betreuteId,
|
||||
immobilieId,
|
||||
punkt.id,
|
||||
!punkt.erledigt,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex size-11 items-center justify-center rounded-md hover:bg-accent"
|
||||
aria-label={
|
||||
punkt.erledigt
|
||||
? `„${punkt.titel}“ wieder öffnen`
|
||||
: `„${punkt.titel}“ abhaken`
|
||||
}
|
||||
>
|
||||
<Checkbox checked={punkt.erledigt} readOnly tabIndex={-1} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<CardTitle className="text-base">{punkt.titel}</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={lage.variante}>{lage.text}</Badge>
|
||||
{punkt.faelligAm && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<CalendarClock className="size-3.5" aria-hidden="true" />
|
||||
fällig {formatDatum(punkt.faelligAm)}
|
||||
</span>
|
||||
)}
|
||||
{punkt.wiederholungRrule && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Repeat className="size-3.5" aria-hidden="true" />
|
||||
wiederkehrend
|
||||
</span>
|
||||
)}
|
||||
{punkt.erledigtAm && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
zuletzt erledigt {formatDatum(punkt.erledigtAm)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{darfSchreiben && (
|
||||
<form
|
||||
action={async () => {
|
||||
await checkpunktLoeschen(betreuteId, immobilieId, punkt.id)
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`„${punkt.titel}“ löschen`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{punkt.beschreibung && (
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">{punkt.beschreibung}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{darfSchreiben && (
|
||||
<div className="rounded-md border p-4">
|
||||
{!offen ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden="true" />
|
||||
Eigenen Punkt hinzufügen
|
||||
</Button>
|
||||
<form action={uebernahmeAktion}>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="immobilieId" value={immobilieId} />
|
||||
<Absendeknopf variant="ghost">Fehlende Standardpunkte nachholen</Absendeknopf>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="immobilieId" value={immobilieId} />
|
||||
|
||||
<Zustandsmeldung zustand={zustand} />
|
||||
|
||||
<Feld id="punkt-titel" beschriftung="Titel" fehler={zustand.felder?.titel} pflicht>
|
||||
<Input name="titel" required maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="punkt-beschreibung"
|
||||
beschriftung="Warum ist der Punkt wichtig?"
|
||||
erklaerung="Schreib es auf. In sechs Monaten weißt du sonst nicht mehr, warum er auf der Liste steht."
|
||||
fehler={zustand.felder?.beschreibung}
|
||||
>
|
||||
<Textarea name="beschreibung" rows={3} maxLength={2000} />
|
||||
</Feld>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="punkt-faellig" beschriftung="Fällig am" fehler={zustand.felder?.faelligAm}>
|
||||
<Input name="faelligAm" type="date" />
|
||||
</Feld>
|
||||
<Feld
|
||||
id="punkt-rrule"
|
||||
beschriftung="Wiederholung"
|
||||
erklaerung="Leer lassen für einen einmaligen Punkt. Sonst z. B. FREQ=WEEKLY;INTERVAL=1 oder FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=1"
|
||||
fehler={zustand.felder?.wiederholungRrule}
|
||||
>
|
||||
<Input name="wiederholungRrule" maxLength={200} />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Absendeknopf>Punkt hinzufügen</Absendeknopf>
|
||||
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld, Feldgruppe } from '@/components/feld'
|
||||
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
||||
import { BEWOHNT_STATUS, BEWOHNT_STATUS_ERKLAERUNG } from '@/lib/immobilie/konstanten'
|
||||
import { immobilieSpeichern } from '@/server/actions/immobilie'
|
||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
export type ImmobilieWerte = {
|
||||
id?: string
|
||||
bezeichnung: string
|
||||
strasse: string
|
||||
plz: string
|
||||
ort: string
|
||||
grundbuchamt: string
|
||||
grundbuchBlatt: string
|
||||
gemarkung: string
|
||||
flurstueck: string
|
||||
eigentumsanteil: string
|
||||
wohnflaecheQm: string
|
||||
grundstueckQm: string
|
||||
baujahr: string
|
||||
bewohntStatus: string
|
||||
leerstandSeit: string
|
||||
notizen: string
|
||||
}
|
||||
|
||||
export const LEERE_IMMOBILIE: ImmobilieWerte = {
|
||||
bezeichnung: '',
|
||||
strasse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
grundbuchamt: '',
|
||||
grundbuchBlatt: '',
|
||||
gemarkung: '',
|
||||
flurstueck: '',
|
||||
eigentumsanteil: '1/1',
|
||||
wohnflaecheQm: '',
|
||||
grundstueckQm: '',
|
||||
baujahr: '',
|
||||
bewohntStatus: '',
|
||||
leerstandSeit: '',
|
||||
notizen: '',
|
||||
}
|
||||
|
||||
export function ImmobilieFormular({
|
||||
betreuteId,
|
||||
werte,
|
||||
}: {
|
||||
betreuteId: string
|
||||
werte: ImmobilieWerte
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(immobilieSpeichern, {})
|
||||
const [bewohnt, setBewohnt] = useState(werte.bewohntStatus)
|
||||
const istNeu = !werte.id
|
||||
const leerstehend = bewohnt === 'leerstehend'
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-8" noValidate>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
{werte.id && <input type="hidden" name="immobilieId" value={werte.id} />}
|
||||
|
||||
<Zustandsmeldung zustand={zustand} />
|
||||
|
||||
<Feldgruppe
|
||||
titel="Objekt"
|
||||
beschreibung="Wie das Grundstück im Alltag heißt und wo es liegt."
|
||||
>
|
||||
<Feld
|
||||
id="bezeichnung"
|
||||
beschriftung="Bezeichnung"
|
||||
erklaerung="Ein Name, unter dem du das Objekt wiedererkennst."
|
||||
fehler={zustand.felder?.bezeichnung}
|
||||
pflicht
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
|
||||
<Input name="strasse" defaultValue={werte.strasse} maxLength={150} />
|
||||
</Feld>
|
||||
|
||||
<div className="grid grid-cols-[7rem_1fr] gap-3">
|
||||
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
|
||||
<Input name="plz" defaultValue={werte.plz} inputMode="numeric" maxLength={10} />
|
||||
</Feld>
|
||||
<Feld id="ort" beschriftung="Ort" fehler={zustand.felder?.ort}>
|
||||
<Input name="ort" defaultValue={werte.ort} maxLength={100} />
|
||||
</Feld>
|
||||
</div>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Grundbuch"
|
||||
beschreibung="Die Angaben stehen auf dem Grundbuchauszug, erste Seite. Ohne sie lässt sich beim Grundbuchamt nichts abfragen."
|
||||
>
|
||||
<Feld
|
||||
id="grundbuchamt"
|
||||
beschriftung="Grundbuchamt"
|
||||
erklaerung="Das Amtsgericht, bei dem das Grundbuch geführt wird."
|
||||
fehler={zustand.felder?.grundbuchamt}
|
||||
>
|
||||
<Input name="grundbuchamt" defaultValue={werte.grundbuchamt} maxLength={150} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="grundbuchBlatt"
|
||||
beschriftung="Grundbuchblatt"
|
||||
erklaerung="Die Blattnummer, unter der das Grundstück eingetragen ist."
|
||||
fehler={zustand.felder?.grundbuchBlatt}
|
||||
>
|
||||
<Input name="grundbuchBlatt" defaultValue={werte.grundbuchBlatt} maxLength={50} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="gemarkung"
|
||||
beschriftung="Gemarkung"
|
||||
erklaerung="Der Vermessungsbezirk — meist der Ortsname."
|
||||
fehler={zustand.felder?.gemarkung}
|
||||
>
|
||||
<Input name="gemarkung" defaultValue={werte.gemarkung} maxLength={100} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="flurstueck"
|
||||
beschriftung="Flurstück"
|
||||
erklaerung="Die Nummer des Flurstücks, z. B. „412/3“."
|
||||
fehler={zustand.felder?.flurstueck}
|
||||
>
|
||||
<Input name="flurstueck" defaultValue={werte.flurstueck} maxLength={50} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="eigentumsanteil"
|
||||
beschriftung="Eigentumsanteil"
|
||||
erklaerung="Wie viel gehört der betreuten Person? „1/1“ heißt Alleineigentum, „1/2“ hälftiger Miteigentumsanteil."
|
||||
fehler={zustand.felder?.eigentumsanteil}
|
||||
>
|
||||
<Input name="eigentumsanteil" defaultValue={werte.eigentumsanteil} maxLength={20} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe titel="Gebäude" beschreibung="Zahlen für die Bewertung und für Anfragen bei Maklern.">
|
||||
<Feld id="wohnflaecheQm" beschriftung="Wohnfläche in m²" fehler={zustand.felder?.wohnflaecheQm}>
|
||||
<Input name="wohnflaecheQm" defaultValue={werte.wohnflaecheQm} inputMode="numeric" />
|
||||
</Feld>
|
||||
<Feld id="grundstueckQm" beschriftung="Grundstücksfläche in m²" fehler={zustand.felder?.grundstueckQm}>
|
||||
<Input name="grundstueckQm" defaultValue={werte.grundstueckQm} inputMode="numeric" />
|
||||
</Feld>
|
||||
<Feld id="baujahr" beschriftung="Baujahr" fehler={zustand.felder?.baujahr}>
|
||||
<Input name="baujahr" defaultValue={werte.baujahr} inputMode="numeric" />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Nutzung"
|
||||
beschreibung="Ob jemand darin wohnt, entscheidet über die Pflichten und über die Behandlung in der Sozialhilfe."
|
||||
>
|
||||
<Feld
|
||||
id="bewohntStatus"
|
||||
beschriftung="Bewohnt-Status"
|
||||
erklaerung={bewohnt ? BEWOHNT_STATUS_ERKLAERUNG[bewohnt as keyof typeof BEWOHNT_STATUS_ERKLAERUNG] : undefined}
|
||||
fehler={zustand.felder?.bewohntStatus}
|
||||
>
|
||||
<Select
|
||||
name="bewohntStatus"
|
||||
value={bewohnt}
|
||||
onChange={(e) => setBewohnt(e.target.value)}
|
||||
>
|
||||
<option value="">— bitte wählen —</option>
|
||||
{BEWOHNT_STATUS.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="leerstandSeit"
|
||||
beschriftung="Leer stehend seit"
|
||||
erklaerung="Ab diesem Tag rechnen die Fälligkeiten der Leerstands-Checkliste."
|
||||
fehler={zustand.felder?.leerstandSeit}
|
||||
pflicht={leerstehend}
|
||||
>
|
||||
<Input name="leerstandSeit" type="date" defaultValue={werte.leerstandSeit} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
{leerstehend && istNeu && (
|
||||
<Alert variant="warnung">
|
||||
<Info aria-hidden="true" />
|
||||
<AlertTitle>Ein leeres Haus ist kein ruhender Vermögensposten</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>
|
||||
Bei Leerstand laufen Pflichten weiter: Der Gebäudeversicherer muss informiert
|
||||
werden — sonst kann er im Schadensfall leistungsfrei werden. Dazu kommen
|
||||
Kontrollgänge, Frostschutz, Winterdienst und die Verkehrssicherung.
|
||||
</p>
|
||||
<label className="flex items-start gap-2.5">
|
||||
<Checkbox name="checklisteUebernehmen" defaultChecked className="mt-0.5" />
|
||||
<span className="text-sm">
|
||||
Pflichten-Checkliste gleich übernehmen (10 Punkte mit Fälligkeiten, jederzeit
|
||||
änderbar)
|
||||
</span>
|
||||
</label>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Feld
|
||||
id="notizen"
|
||||
beschriftung="Notizen"
|
||||
erklaerung="Wird verschlüsselt gespeichert."
|
||||
fehler={zustand.felder?.notizen}
|
||||
>
|
||||
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
|
||||
</Feld>
|
||||
|
||||
<Absendeknopf>{istNeu ? 'Objekt anlegen' : 'Änderungen speichern'}</Absendeknopf>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Camera, Package, Plus, Trash2 } 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 { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
||||
import { VERBLEIB_MOEGLICHKEITEN } from '@/lib/immobilie/inventar'
|
||||
import { inventarLoeschen, inventarSpeichern } from '@/server/actions/immobilie'
|
||||
import { formatCent, formatDatum } from '@/lib/utils'
|
||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
export type InventarZeile = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
raum: string | null
|
||||
verbleib: string | null
|
||||
bemerkung: string | null
|
||||
wertCent: number | null
|
||||
fotos: { id: string; titel: string; aufgenommenAm: string | null }[]
|
||||
}
|
||||
|
||||
export function Inventar({
|
||||
betreuteId,
|
||||
immobilieId,
|
||||
stuecke,
|
||||
summeCent,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
betreuteId: string
|
||||
immobilieId: string
|
||||
stuecke: InventarZeile[]
|
||||
summeCent: number
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(inventarSpeichern, {})
|
||||
const [offen, setOffen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Camera className="size-5" aria-hidden="true" />
|
||||
Fotodokumentation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Fotografiere jeden Raum vollständig, bevor etwas bewegt wird, dazu die
|
||||
Wertgegenstände einzeln. Das schützt vor dem Vorwurf, es sei etwas verschwunden,
|
||||
und belegt den Zustand des Hauses gegenüber Käufern und Versicherern.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/dokumente/neu?art=FOTO&immobilieId=${immobilieId}`}>
|
||||
<Camera aria-hidden="true" />
|
||||
Fotos hochladen
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Die Fotos werden als Dokument mit der Art „Foto“ abgelegt und mit dem Objekt
|
||||
verknüpft. Einzelne Gegenstände verknüpfst du zusätzlich mit dem passenden
|
||||
Inventarposten.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="size-5" aria-hidden="true" />
|
||||
Inventarliste der Wertgegenstände
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Was ist da, wo steht es, was ist es ungefähr wert, und wo ist es geblieben? Die
|
||||
Liste beantwortet später die Frage von Miterben und Gericht, was mit dem Hausrat
|
||||
passiert ist.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{stuecke.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch nichts erfasst"
|
||||
beschreibung="Fang mit dem an, was auffällt: Schmuck, Uhren, Bilder, Werkzeug, ein Fahrzeug. Alltagsgegenstände einzeln aufzunehmen lohnt sich nicht — dafür genügen die Raumfotos."
|
||||
icon={Package}
|
||||
aktion={
|
||||
darfSchreiben && (
|
||||
<Button type="button" onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden="true" />
|
||||
Gegenstand aufnehmen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Bezeichnung</TableHead>
|
||||
<TableHead>Raum</TableHead>
|
||||
<TableHead className="text-right">geschätzter Wert</TableHead>
|
||||
<TableHead>Verbleib</TableHead>
|
||||
<TableHead>Fotos</TableHead>
|
||||
{darfSchreiben && <TableHead className="sr-only">Aktion</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stuecke.map((stueck) => (
|
||||
<TableRow key={stueck.id}>
|
||||
<TableCell>
|
||||
<span className="font-medium">{stueck.bezeichnung}</span>
|
||||
{stueck.bemerkung && (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{stueck.bemerkung}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{stueck.raum ?? '–'}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCent(stueck.wertCent)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{stueck.verbleib ? (
|
||||
<Badge variant="outline">{stueck.verbleib}</Badge>
|
||||
) : (
|
||||
'–'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{stueck.fotos.length === 0
|
||||
? 'keine'
|
||||
: stueck.fotos
|
||||
.map((f) => `${f.titel}${f.aufgenommenAm ? ` (${formatDatum(f.aufgenommenAm)})` : ''}`)
|
||||
.join(', ')}
|
||||
</TableCell>
|
||||
{darfSchreiben && (
|
||||
<TableCell>
|
||||
<form
|
||||
action={async () => {
|
||||
await inventarLoeschen(betreuteId, immobilieId, stueck.id)
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${stueck.bezeichnung} entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Summe der geschätzten Werte:{' '}
|
||||
<strong className="text-foreground">{formatCent(summeCent)}</strong>. Liegt sie
|
||||
niedrig, gehört das begründet in die Akte — dann lohnt sich eine Verwertung über
|
||||
einen Händler nicht, und die Kosten einer Haushaltsauflösung sind der
|
||||
sinnvollere Weg.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{darfSchreiben && (offen || stuecke.length > 0) && (
|
||||
<div className="rounded-md border p-4">
|
||||
{!offen ? (
|
||||
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden="true" />
|
||||
Weiteren Gegenstand aufnehmen
|
||||
</Button>
|
||||
) : (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="immobilieId" value={immobilieId} />
|
||||
|
||||
<Zustandsmeldung zustand={zustand} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="inventar-bezeichnung"
|
||||
beschriftung="Bezeichnung"
|
||||
fehler={zustand.felder?.bezeichnung}
|
||||
pflicht
|
||||
>
|
||||
<Input name="bezeichnung" required maxLength={150} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="inventar-raum" beschriftung="Raum" fehler={zustand.felder?.raum}>
|
||||
<Input name="raum" maxLength={100} placeholder="Wohnzimmer" />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="inventar-wert"
|
||||
beschriftung="Geschätzter Wert"
|
||||
erklaerung="Eigene Schätzung. Wird mit dem heutigen Tag als Stichtag abgelegt."
|
||||
fehler={zustand.felder?.geschaetzterWert}
|
||||
>
|
||||
<Input name="geschaetzterWert" inputMode="decimal" placeholder="250,00" />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="inventar-verbleib"
|
||||
beschriftung="Verbleib"
|
||||
fehler={zustand.felder?.verbleib}
|
||||
>
|
||||
<Select name="verbleib" defaultValue="im Haus">
|
||||
{VERBLEIB_MOEGLICHKEITEN.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
id="inventar-bemerkung"
|
||||
beschriftung="Bemerkung"
|
||||
erklaerung="Wird verschlüsselt gespeichert."
|
||||
fehler={zustand.felder?.bemerkung}
|
||||
>
|
||||
<Textarea name="bemerkung" rows={2} maxLength={1000} />
|
||||
</Feld>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Absendeknopf>Gegenstand aufnehmen</Absendeknopf>
|
||||
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Link from 'next/link'
|
||||
import { Info, Receipt } 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 { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { IMMOBILIEN_KATEGORIE_BESCHRIFTUNG } from '@/lib/immobilie/konstanten'
|
||||
import type { Kostenjahr } from '@/lib/immobilie/kostenkonto'
|
||||
import { formatCent } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Kostenkonto des Objekts - reine Anzeige.
|
||||
*
|
||||
* Die Buchungen legt das Finanzmodul an. Hier steht nur die Auswertung:
|
||||
* was kostet das Haus im Jahr, ohne dass jemand darin wohnt.
|
||||
*/
|
||||
export function Kostenkonto({
|
||||
jahre,
|
||||
durchschnittCent,
|
||||
laufendesJahr,
|
||||
mehrereObjekte,
|
||||
}: {
|
||||
jahre: (Omit<Kostenjahr, 'ausgabenCent' | 'einnahmenCent' | 'saldoCent' | 'ausgaben' | 'einnahmen'> & {
|
||||
ausgabenCent: number
|
||||
einnahmenCent: number
|
||||
saldoCent: number
|
||||
ausgaben: { kategorie: string; summeCent: number; anzahl: number }[]
|
||||
einnahmen: { kategorie: string; summeCent: number; anzahl: number }[]
|
||||
})[]
|
||||
durchschnittCent: number
|
||||
laufendesJahr: number
|
||||
mehrereObjekte: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="size-5" aria-hidden="true" />
|
||||
Kostenkonto
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Was das Objekt kostet — zusammengestellt aus dem Kassenbuch, je Jahr summiert.
|
||||
Grundsteuer, Betriebskosten (Gebäudeversicherung, Energie, Wasser,
|
||||
Schornsteinfeger) und Instandhaltung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{jahre.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch keine Buchungen zum Objekt"
|
||||
beschreibung="Sobald im Kassenbuch Buchungen mit den Kategorien Grundsteuer, Immobilien-Betriebskosten oder Instandhaltung stehen, erscheinen sie hier automatisch."
|
||||
icon={Receipt}
|
||||
aktion={
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/finanzen/buchungen/neu">Buchung erfassen</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{jahre.map((jahr) => (
|
||||
<div key={jahr.jahr} className="space-y-2">
|
||||
<h3 className="text-base font-semibold">
|
||||
{jahr.jahr}
|
||||
{jahr.jahr === laufendesJahr && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
(läuft noch)
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kategorie</TableHead>
|
||||
<TableHead className="text-right">Buchungen</TableHead>
|
||||
<TableHead className="text-right">Summe</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jahr.ausgaben.map((zeile) => (
|
||||
<TableRow key={zeile.kategorie}>
|
||||
<TableCell>
|
||||
{IMMOBILIEN_KATEGORIE_BESCHRIFTUNG[
|
||||
zeile.kategorie as keyof typeof IMMOBILIEN_KATEGORIE_BESCHRIFTUNG
|
||||
] ?? zeile.kategorie}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{zeile.anzahl}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCent(zeile.summeCent)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{jahr.einnahmen.map((zeile) => (
|
||||
<TableRow key={zeile.kategorie}>
|
||||
<TableCell>
|
||||
{IMMOBILIEN_KATEGORIE_BESCHRIFTUNG[
|
||||
zeile.kategorie as keyof typeof IMMOBILIEN_KATEGORIE_BESCHRIFTUNG
|
||||
] ?? zeile.kategorie}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{zeile.anzahl}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-ampel-gruen">
|
||||
+ {formatCent(zeile.summeCent)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="border-t-2">
|
||||
<TableCell className="font-semibold">
|
||||
{jahr.saldoCent < 0 ? 'Kosten im Jahr' : 'Überschuss im Jahr'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{jahr.anzahlBuchungen}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">
|
||||
{formatCent(Math.abs(jahr.saldoCent))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{durchschnittCent > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Durchschnittliche Kosten je abgeschlossenem Jahr:{' '}
|
||||
<strong className="text-foreground">{formatCent(durchschnittCent)}</strong>. Diese
|
||||
Zahl gehört in jedes Gespräch über Verkauf, Härtefall oder ein Darlehen nach
|
||||
§ 91 SGB XII — ein leerstehendes Haus verzehrt Vermögen, auch wenn niemand
|
||||
darin wohnt.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2.5 rounded-md border bg-muted/40 p-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ausgewertet wird über die Buchungskategorien, weil eine Buchung im Datenmodell noch
|
||||
keinem einzelnen Objekt zugeordnet werden kann.{' '}
|
||||
{mehrereObjekte ? (
|
||||
<strong className="text-foreground">
|
||||
Es sind mehrere Objekte erfasst — die Zahlen enthalten die Kosten aller
|
||||
Grundstücke zusammen.
|
||||
</strong>
|
||||
) : (
|
||||
'Solange nur ein Objekt geführt wird, ist die Zuordnung eindeutig.'
|
||||
)}{' '}
|
||||
Buche die Gebäudeversicherung als Immobilien-Betriebskosten, sonst taucht sie hier
|
||||
nicht auf.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import type { ImmobilienStatusSozialhilfe } from '@prisma/client'
|
||||
import { Landmark } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
||||
import {
|
||||
IMMOBILIEN_STATUS_BESCHRIFTUNG,
|
||||
IMMOBILIEN_STATUS_ERKLAERUNG,
|
||||
IMMOBILIEN_STATUS_RECHTSGRUNDLAGE,
|
||||
} from '@/lib/immobilie/konstanten'
|
||||
import { statusSozialhilfeSpeichern } from '@/server/actions/immobilie'
|
||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
const REIHENFOLGE: ImmobilienStatusSozialhilfe[] = [
|
||||
'UNGEKLAERT',
|
||||
'SELBST_BEWOHNT_GESCHUETZT',
|
||||
'HAERTEFALL_GELTEND_GEMACHT',
|
||||
'DARLEHEN_91_SGB_XII',
|
||||
'VERKAUF_LAEUFT',
|
||||
'VERWERTET',
|
||||
]
|
||||
|
||||
export function StatusSozialhilfe({
|
||||
betreuteId,
|
||||
immobilieId,
|
||||
status,
|
||||
begruendung,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
betreuteId: string
|
||||
immobilieId: string
|
||||
status: ImmobilienStatusSozialhilfe
|
||||
begruendung: string | null
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
|
||||
statusSozialhilfeSpeichern,
|
||||
{},
|
||||
)
|
||||
const [gewaehlt, setGewaehlt] = useState<ImmobilienStatusSozialhilfe>(status)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Landmark className="size-5" aria-hidden="true" />
|
||||
Status im Sozialhilfeverfahren
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Wie das Grundstück gegenüber dem Bezirk behandelt wird — und warum. Die Begründung
|
||||
ist deine Argumentation, keine Rechtsauskunft.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{darfSchreiben ? (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="immobilieId" value={immobilieId} />
|
||||
|
||||
<Zustandsmeldung zustand={zustand} />
|
||||
|
||||
<Feld
|
||||
id="statusSozialhilfe"
|
||||
beschriftung="Einordnung"
|
||||
erklaerung={IMMOBILIEN_STATUS_ERKLAERUNG[gewaehlt]}
|
||||
fehler={zustand.felder?.statusSozialhilfe}
|
||||
pflicht
|
||||
>
|
||||
<Select
|
||||
name="statusSozialhilfe"
|
||||
value={gewaehlt}
|
||||
onChange={(e) => setGewaehlt(e.target.value as ImmobilienStatusSozialhilfe)}
|
||||
>
|
||||
{REIHENFOLGE.map((wert) => (
|
||||
<option key={wert} value={wert}>
|
||||
{IMMOBILIEN_STATUS_BESCHRIFTUNG[wert]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="begruendung"
|
||||
beschriftung="Begründung"
|
||||
erklaerung="Wird verschlüsselt gespeichert. Nenne Datum und Gesprächspartner, wenn die Einordnung auf einer Auskunft des Bezirks beruht."
|
||||
fehler={zustand.felder?.begruendung}
|
||||
pflicht={gewaehlt !== 'UNGEKLAERT'}
|
||||
>
|
||||
<Textarea name="begruendung" defaultValue={begruendung ?? ''} rows={5} maxLength={4000} />
|
||||
</Feld>
|
||||
|
||||
<Absendeknopf>Status speichern</Absendeknopf>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">{IMMOBILIEN_STATUS_BESCHRIFTUNG[status]}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{IMMOBILIEN_STATUS_ERKLAERUNG[status]}
|
||||
</p>
|
||||
{begruendung && <p className="whitespace-pre-line text-sm">{begruendung}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage={IMMOBILIEN_STATUS_RECHTSGRUNDLAGE} />
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kurz übersetzt: § 90 Abs. 2 Nr. 8 SGB XII schützt ein angemessenes Hausgrundstück,
|
||||
solange die betreute Person es selbst bewohnt. Nach einem dauerhaften Heimeinzug
|
||||
sehen die Träger diesen Schutz regelmäßig als entfallen an. Dann bleibt § 91 SGB XII:
|
||||
Die Hilfe wird als Darlehen gewährt und in der Regel durch eine Grundschuld
|
||||
gesichert, statt sofort zum Verkauf zu zwingen. Ob im Einzelfall eine Härte nach
|
||||
§ 90 Abs. 3 SGB XII vorliegt, entscheidet weder diese Anwendung noch der Sachbearbeiter
|
||||
am Telefon — das gehört schriftlich beantragt und im Zweifel überprüft.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { LineChart, Plus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
||||
import { WERTQUELLEN_IMMOBILIE, WERTQUELLE_ERKLAERUNG } from '@/lib/immobilie/konstanten'
|
||||
import { wertstandErfassen } from '@/server/actions/immobilie'
|
||||
import { formatCent, formatDatum } from '@/lib/utils'
|
||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
export type WertZeile = {
|
||||
id: string
|
||||
stichtag: string
|
||||
wertCent: number
|
||||
wertquelle: string
|
||||
bemerkung: string | null
|
||||
}
|
||||
|
||||
export function WertHistorie({
|
||||
betreuteId,
|
||||
immobilieId,
|
||||
werte,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
betreuteId: string
|
||||
immobilieId: string
|
||||
werte: WertZeile[]
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(wertstandErfassen, {})
|
||||
const [offen, setOffen] = useState(false)
|
||||
const [quelle, setQuelle] = useState<string>(WERTQUELLEN_IMMOBILIE[0])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LineChart className="size-5" aria-hidden="true" />
|
||||
Wert-Historie
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Jeder Wert bekommt einen Stichtag und eine Quelle. Nichts wird überschrieben — jede
|
||||
Vermögensübersicht muss Jahre später noch nachvollziehbar sein.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{werte.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch kein Wert hinterlegt"
|
||||
beschreibung="Der Bodenrichtwert ist kostenlos und schnell zu bekommen (BORIS Bayern, Gutachterausschuss). Er ist keine Bewertung, aber ein belastbarer Anfang."
|
||||
icon={LineChart}
|
||||
aktion={
|
||||
darfSchreiben && (
|
||||
<Button type="button" onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden="true" />
|
||||
Wert eintragen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stichtag</TableHead>
|
||||
<TableHead className="text-right">Wert</TableHead>
|
||||
<TableHead>Quelle</TableHead>
|
||||
<TableHead>Bemerkung</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{werte.map((zeile) => (
|
||||
<TableRow key={zeile.id}>
|
||||
<TableCell className="whitespace-nowrap">{formatDatum(zeile.stichtag)}</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">
|
||||
{formatCent(zeile.wertCent)}
|
||||
</TableCell>
|
||||
<TableCell>{zeile.wertquelle}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{zeile.bemerkung ?? '–'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{darfSchreiben && (offen || werte.length > 0) && (
|
||||
<div className="rounded-md border p-4">
|
||||
{!offen ? (
|
||||
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden="true" />
|
||||
Weiteren Wert eintragen
|
||||
</Button>
|
||||
) : (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="immobilieId" value={immobilieId} />
|
||||
|
||||
<Zustandsmeldung zustand={zustand} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="wert-stichtag" beschriftung="Stichtag" fehler={zustand.felder?.stichtag} pflicht>
|
||||
<Input name="stichtag" type="date" required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="wert-betrag"
|
||||
beschriftung="Wert"
|
||||
erklaerung="Der Wert des gesamten Objekts, nicht des Miteigentumsanteils."
|
||||
fehler={zustand.felder?.wert}
|
||||
pflicht
|
||||
>
|
||||
<Input name="wert" inputMode="decimal" placeholder="185.000,00" required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="wert-quelle"
|
||||
beschriftung="Wertquelle"
|
||||
erklaerung={WERTQUELLE_ERKLAERUNG[quelle as keyof typeof WERTQUELLE_ERKLAERUNG]}
|
||||
fehler={zustand.felder?.wertquelle}
|
||||
pflicht
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Select name="wertquelle" value={quelle} onChange={(e) => setQuelle(e.target.value)}>
|
||||
{WERTQUELLEN_IMMOBILIE.map((q) => (
|
||||
<option key={q} value={q}>
|
||||
{q}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feld id="wert-bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
|
||||
<Textarea
|
||||
name="bemerkung"
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="z. B. Gutachterausschuss Bayreuth, Richtwertzone 12, 210 €/m²"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Absendeknopf>Wert aufnehmen</Absendeknopf>
|
||||
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
BescheidPruefStatus,
|
||||
LeistungStatus,
|
||||
NachforderungStatus,
|
||||
SozialhilfeBewertung,
|
||||
} from '@prisma/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { PRUEFSTATUS_BESCHRIFTUNG, PRUEFSTATUS_VARIANTE } from '@/lib/sozialhilfe/bescheide'
|
||||
import {
|
||||
LEISTUNG_STATUS_BESCHRIFTUNG,
|
||||
LEISTUNG_STATUS_VARIANTE,
|
||||
} from '@/lib/sozialhilfe/leistungskatalog'
|
||||
import {
|
||||
NACHFORDERUNG_BESCHRIFTUNG,
|
||||
NACHFORDERUNG_VARIANTE,
|
||||
} from '@/lib/sozialhilfe/nachforderungen'
|
||||
import { BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_VARIANTE } from '@/lib/sozialhilfe/schonvermoegen'
|
||||
|
||||
/**
|
||||
* Statuskennzeichen des Moduls.
|
||||
*
|
||||
* Alle tragen Text, nicht nur Farbe - wer rot und grün nicht unterscheidet,
|
||||
* soll trotzdem sehen, woran er ist.
|
||||
*/
|
||||
|
||||
export function NachforderungAbzeichen({ status }: { status: NachforderungStatus }) {
|
||||
return <Badge variant={NACHFORDERUNG_VARIANTE[status]}>{NACHFORDERUNG_BESCHRIFTUNG[status]}</Badge>
|
||||
}
|
||||
|
||||
export function BescheidAbzeichen({ status }: { status: BescheidPruefStatus }) {
|
||||
return <Badge variant={PRUEFSTATUS_VARIANTE[status]}>{PRUEFSTATUS_BESCHRIFTUNG[status]}</Badge>
|
||||
}
|
||||
|
||||
export function BewertungAbzeichen({ bewertung }: { bewertung: SozialhilfeBewertung }) {
|
||||
return <Badge variant={BEWERTUNG_VARIANTE[bewertung]}>{BEWERTUNG_BESCHRIFTUNG[bewertung]}</Badge>
|
||||
}
|
||||
|
||||
export function LeistungAbzeichen({ status }: { status: LeistungStatus }) {
|
||||
return (
|
||||
<Badge variant={LEISTUNG_STATUS_VARIANTE[status]}>{LEISTUNG_STATUS_BESCHRIFTUNG[status]}</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Freie Ampel für berechnete Lagen (Fristen, Deckungslücke). */
|
||||
export function AmpelAbzeichen({
|
||||
variante,
|
||||
text,
|
||||
}: {
|
||||
variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
|
||||
text: string
|
||||
}) {
|
||||
return <Badge variant={variante}>{text}</Badge>
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Feld, Feldgruppe } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import { antragSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
|
||||
export type AntragWerte = {
|
||||
id: string | null
|
||||
traeger: string
|
||||
leistungsart: string
|
||||
aktenzeichen: string
|
||||
sachbearbeiter: string
|
||||
telefon: string
|
||||
email: string
|
||||
antragAm: string
|
||||
eingangBeimTraegerAm: string
|
||||
bewilligtVon: string
|
||||
bewilligtBis: string
|
||||
notizen: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Antragsakte: wer bearbeitet den Fall, unter welchem Aktenzeichen,
|
||||
* seit wann und für welchen Zeitraum bewilligt.
|
||||
*
|
||||
* Mehrere Anträge nebeneinander sind der Normalfall - Erstantrag,
|
||||
* Weiterbewilligung, dazu vielleicht ein eigener Antrag auf Grundsicherung.
|
||||
* Jeder bekommt seine eigene Akte mit eigenen Nachforderungen und
|
||||
* Bescheiden.
|
||||
*/
|
||||
export function AntragFormular({ werte }: { werte: AntragWerte }) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(antragSpeichern, {})
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-6">
|
||||
{werte.id && <input type="hidden" name="antragId" value={werte.id} />}
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-6 pt-5">
|
||||
<Feldgruppe
|
||||
titel="Träger und Leistung"
|
||||
beschreibung="In Bayern ist für Leistungen in stationären Einrichtungen der Bezirk zuständig, nicht der Landkreis."
|
||||
>
|
||||
<Feld
|
||||
id="traeger"
|
||||
beschriftung="Träger"
|
||||
pflicht
|
||||
fehler={zustand.felder?.traeger}
|
||||
erklaerung="Wer entscheidet über den Antrag?"
|
||||
>
|
||||
<Input name="traeger" defaultValue={werte.traeger} required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="leistungsart"
|
||||
beschriftung="Leistungsart"
|
||||
pflicht
|
||||
fehler={zustand.felder?.leistungsart}
|
||||
erklaerung="z.B. Hilfe zur Pflege nach dem 7. Kapitel SGB XII"
|
||||
>
|
||||
<Input name="leistungsart" defaultValue={werte.leistungsart} required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="aktenzeichen"
|
||||
beschriftung="Aktenzeichen"
|
||||
fehler={zustand.felder?.aktenzeichen}
|
||||
erklaerung="Steht auf jedem Schreiben des Trägers. Gehört in jede Rückmeldung."
|
||||
>
|
||||
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Sachbearbeitung"
|
||||
beschreibung="Ein Anruf klärt oft schneller als drei Briefe. Deshalb gehört die Durchwahl hierher."
|
||||
>
|
||||
<Feld
|
||||
id="sachbearbeiter"
|
||||
beschriftung="Sachbearbeiter/in"
|
||||
fehler={zustand.felder?.sachbearbeiter}
|
||||
>
|
||||
<Input name="sachbearbeiter" defaultValue={werte.sachbearbeiter} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="telefon" beschriftung="Telefon" fehler={zustand.felder?.telefon}>
|
||||
<Input name="telefon" type="tel" defaultValue={werte.telefon} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="email" beschriftung="E-Mail" fehler={zustand.felder?.email}>
|
||||
<Input name="email" type="email" defaultValue={werte.email} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Verfahrensdaten"
|
||||
beschreibung="Das Eingangsdatum beim Träger ist wichtig: Leistungen setzen ab Kenntnis des Trägers ein (§ 18 SGB XII), nicht erst ab der Bewilligung."
|
||||
>
|
||||
<Feld
|
||||
id="antragAm"
|
||||
beschriftung="Antrag gestellt am"
|
||||
fehler={zustand.felder?.antragAm}
|
||||
erklaerung="Datum auf deinem Antragsschreiben."
|
||||
>
|
||||
<Input name="antragAm" type="date" defaultValue={werte.antragAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="eingangBeimTraegerAm"
|
||||
beschriftung="Eingang beim Träger"
|
||||
fehler={zustand.felder?.eingangBeimTraegerAm}
|
||||
erklaerung="Ab wann wusste der Träger Bescheid? Eingangsstempel oder Sendebericht aufheben."
|
||||
>
|
||||
<Input
|
||||
name="eingangBeimTraegerAm"
|
||||
type="date"
|
||||
defaultValue={werte.eingangBeimTraegerAm}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="bewilligtVon"
|
||||
beschriftung="Bewilligt von"
|
||||
fehler={zustand.felder?.bewilligtVon}
|
||||
>
|
||||
<Input name="bewilligtVon" type="date" defaultValue={werte.bewilligtVon} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="bewilligtBis"
|
||||
beschriftung="Bewilligt bis"
|
||||
fehler={zustand.felder?.bewilligtBis}
|
||||
erklaerung="Vor dem Ablauf rechtzeitig die Weiterbewilligung beantragen — sonst reißt die Zahlung ab."
|
||||
>
|
||||
<Input name="bewilligtBis" type="date" defaultValue={werte.bewilligtBis} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feld
|
||||
id="notizen"
|
||||
beschriftung="Notizen"
|
||||
fehler={zustand.felder?.notizen}
|
||||
erklaerung="Wird verschlüsselt gespeichert. Was am Telefon besprochen wurde, gehört hierher."
|
||||
>
|
||||
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} />
|
||||
</Feld>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 18 SGB XII" />
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Absenden>{werte.id ? 'Änderungen speichern' : 'Antragsakte anlegen'}</Absenden>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href={werte.id ? `/sozialhilfe/${werte.id}` : '/sozialhilfe'}>Abbrechen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||
import type { Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
|
||||
/**
|
||||
* Kleinteile, die in allen Formularen des Sozialhilfemoduls
|
||||
* wiederkehren. Sie liegen hier zusammen, damit nicht jedes Formular
|
||||
* seinen eigenen Absende-Knopf und seine eigene Meldung erfindet.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Absende-Knopf, der während des Speicherns gesperrt ist. Ohne die Sperre
|
||||
* entstehen bei langsamer Verbindung Dubletten - und eine doppelt
|
||||
* angelegte Nachforderung fällt erst beim Bezirk auf.
|
||||
*/
|
||||
export function Absenden({
|
||||
children = 'Speichern',
|
||||
laedtText = 'Wird gespeichert …',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps & { laedtText?: string }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" {...props} disabled={pending || disabled}>
|
||||
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
|
||||
{pending ? laedtText : children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Rückmeldung einer Server Action - Fehler oder Erfolg. */
|
||||
export function Meldung({ zustand }: { zustand: Formularzustand }) {
|
||||
if (zustand.fehler) {
|
||||
return (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
if (zustand.erfolg) {
|
||||
return (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.erfolg}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Knopf, der eine Aktion ohne Formularfelder auslöst und vorher
|
||||
* nachfragt. Für Löschen und andere Schritte, die man nicht versehentlich
|
||||
* gehen will.
|
||||
*/
|
||||
export function AktionMitRueckfrage({
|
||||
aktion,
|
||||
frage,
|
||||
children,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
}: {
|
||||
aktion: () => Promise<void>
|
||||
frage: string
|
||||
children: React.ReactNode
|
||||
variant?: ButtonProps['variant']
|
||||
size?: ButtonProps['size']
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
action={aktion}
|
||||
onSubmit={(ereignis) => {
|
||||
if (!window.confirm(frage)) ereignis.preventDefault()
|
||||
}}
|
||||
>
|
||||
<Absenden variant={variant} size={size} laedtText="Einen Moment …">
|
||||
{children}
|
||||
</Absenden>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { BescheidArt } from '@prisma/client'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import { BESCHEID_ART_BESCHRIFTUNG, BESCHEID_ART_ERKLAERUNG, BESCHEID_ARTEN } from '@/lib/sozialhilfe/bescheide'
|
||||
import { BELEHRUNG_ERKLAERUNG } from '@/lib/sozialhilfe/rechtsbehelf'
|
||||
import { bescheidSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
export type BescheidWerte = {
|
||||
id: string | null
|
||||
art: BescheidArt
|
||||
bescheidVom: string
|
||||
zugegangenAm: string
|
||||
zeitraumVon: string
|
||||
zeitraumBis: string
|
||||
betrag: string
|
||||
rechtsbehelfsfristMonate: number
|
||||
widerspruchEingelegtAm: string
|
||||
notizen: string
|
||||
}
|
||||
|
||||
export const LEERER_BESCHEID: BescheidWerte = {
|
||||
id: null,
|
||||
art: 'BEWILLIGUNG',
|
||||
bescheidVom: '',
|
||||
zugegangenAm: '',
|
||||
zeitraumVon: '',
|
||||
zeitraumBis: '',
|
||||
betrag: '',
|
||||
rechtsbehelfsfristMonate: 1,
|
||||
widerspruchEingelegtAm: '',
|
||||
notizen: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Bescheid erfassen.
|
||||
*
|
||||
* Das wichtigste Feld ist das Zugangsdatum, nicht das Datum auf dem
|
||||
* Bescheid. Die Widerspruchsfrist läuft ab Bekanntgabe (§ 84 Abs. 1 SGG) -
|
||||
* zwischen Ausstellung und Zugang liegen fast immer mehrere Tage, und wer
|
||||
* vom falschen Tag aus rechnet, verschenkt sie.
|
||||
*/
|
||||
export function BescheidFormular({
|
||||
antragId,
|
||||
werte,
|
||||
zugangsvorschlag,
|
||||
}: {
|
||||
antragId: string
|
||||
werte: BescheidWerte
|
||||
/** Vorschlag nach § 37 Abs. 2 SGB X, sobald ein Bescheiddatum steht. */
|
||||
zugangsvorschlag?: { datum: string; hinweis: string } | null
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(bescheidSpeichern, {})
|
||||
const [art, setArt] = useState<BescheidArt>(werte.art)
|
||||
const [monate, setMonate] = useState(String(werte.rechtsbehelfsfristMonate))
|
||||
const [zugegangenAm, setZugegangenAm] = useState(werte.zugegangenAm)
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-6">
|
||||
<input type="hidden" name="antragId" value={antragId} />
|
||||
{werte.id && <input type="hidden" name="bescheidId" value={werte.id} />}
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-5 pt-5">
|
||||
<Feld
|
||||
id="art"
|
||||
beschriftung="Art des Bescheids"
|
||||
pflicht
|
||||
fehler={zustand.felder?.art}
|
||||
erklaerung={BESCHEID_ART_ERKLAERUNG[art]}
|
||||
>
|
||||
<Select name="art" value={art} onChange={(e) => setArt(e.target.value as BescheidArt)}>
|
||||
{BESCHEID_ARTEN.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{BESCHEID_ART_BESCHRIFTUNG[a]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="bescheidVom"
|
||||
beschriftung="Bescheid vom"
|
||||
pflicht
|
||||
fehler={zustand.felder?.bescheidVom}
|
||||
erklaerung="Das Datum, das oben auf dem Schreiben steht."
|
||||
>
|
||||
<Input name="bescheidVom" type="date" defaultValue={werte.bescheidVom} required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="zugegangenAm"
|
||||
beschriftung="Zugegangen am"
|
||||
fehler={zustand.felder?.zugegangenAm}
|
||||
erklaerung="Maßgeblich für den Fristbeginn — nicht das Datum auf dem Bescheid. Hebe den Briefumschlag mit dem Poststempel auf."
|
||||
>
|
||||
<Input
|
||||
name="zugegangenAm"
|
||||
type="date"
|
||||
value={zugegangenAm}
|
||||
onChange={(e) => setZugegangenAm(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
{zugangsvorschlag && zugegangenAm === '' && (
|
||||
<Alert variant="hinweis">
|
||||
<Info aria-hidden="true" />
|
||||
<AlertTitle>Zugangsvermutung</AlertTitle>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>{zugangsvorschlag.hinweis}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setZugegangenAm(zugangsvorschlag.datum)}
|
||||
>
|
||||
{zugangsvorschlag.datum.split('-').reverse().join('.')} übernehmen
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Feld
|
||||
id="rechtsbehelfsfristMonate"
|
||||
beschriftung="Rechtsbehelfsbelehrung"
|
||||
fehler={zustand.felder?.rechtsbehelfsfristMonate}
|
||||
erklaerung={
|
||||
BELEHRUNG_ERKLAERUNG[monate === '12' ? 'FEHLT_ODER_FEHLERHAFT' : 'ORDNUNGSGEMAESS']
|
||||
}
|
||||
>
|
||||
<Select
|
||||
name="rechtsbehelfsfristMonate"
|
||||
value={monate}
|
||||
onChange={(e) => setMonate(e.target.value)}
|
||||
>
|
||||
<option value="1">Vorhanden und richtig — ein Monat (§ 84 SGG)</option>
|
||||
<option value="12">Fehlt oder ist fehlerhaft — ein Jahr (§ 66 SGG)</option>
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="zeitraumVon"
|
||||
beschriftung="Zeitraum von"
|
||||
fehler={zustand.felder?.zeitraumVon}
|
||||
>
|
||||
<Input name="zeitraumVon" type="date" defaultValue={werte.zeitraumVon} />
|
||||
</Feld>
|
||||
<Feld id="zeitraumBis" beschriftung="Zeitraum bis" fehler={zustand.felder?.zeitraumBis}>
|
||||
<Input name="zeitraumBis" type="date" defaultValue={werte.zeitraumBis} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="betrag"
|
||||
beschriftung="Betrag"
|
||||
fehler={zustand.felder?.betrag}
|
||||
erklaerung="Monatlicher oder einmaliger Betrag laut Bescheid, z.B. 1.234,56"
|
||||
>
|
||||
<Input name="betrag" inputMode="decimal" defaultValue={werte.betrag} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="widerspruchEingelegtAm"
|
||||
beschriftung="Widerspruch eingelegt am"
|
||||
fehler={zustand.felder?.widerspruchEingelegtAm}
|
||||
erklaerung="Nur ausfüllen, wenn der Widerspruch bereits raus ist."
|
||||
>
|
||||
<Input
|
||||
name="widerspruchEingelegtAm"
|
||||
type="date"
|
||||
defaultValue={werte.widerspruchEingelegtAm}
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feld
|
||||
id="notizen"
|
||||
beschriftung="Notizen"
|
||||
fehler={zustand.felder?.notizen}
|
||||
erklaerung="Wird verschlüsselt gespeichert. Was ist auffällig? Welche Position stimmt nicht?"
|
||||
>
|
||||
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} />
|
||||
</Feld>
|
||||
|
||||
{zugegangenAm !== '' && (
|
||||
<label className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
|
||||
<Checkbox name="fristAnlegen" className="mt-0.5" defaultChecked />
|
||||
<span>
|
||||
Frist „Widerspruch möglich bis“ im Fristenmodul anlegen.
|
||||
<span className="mt-0.5 block text-muted-foreground">
|
||||
Mit dem berechneten Fristende und Vorwarnung 21, 14, 7, 3 und 1 Tag vorher. Der
|
||||
Rechenweg steht in der Beschreibung der Frist.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 84 SGG, § 66 SGG" />
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Absenden>{werte.id ? 'Änderungen speichern' : 'Bescheid erfassen'}</Absenden>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href={`/sozialhilfe/${antragId}`}>Abbrechen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import type { BescheidPruefStatus } from '@prisma/client'
|
||||
import { Scale } from 'lucide-react'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import { BescheidAbzeichen } from '@/components/sozialhilfe/abzeichen'
|
||||
import {
|
||||
PRUEFSTATUS,
|
||||
PRUEFSTATUS_BESCHRIFTUNG,
|
||||
PRUEFSTATUS_ERKLAERUNG,
|
||||
} from '@/lib/sozialhilfe/bescheide'
|
||||
import { bescheidStatusSetzen, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
|
||||
/**
|
||||
* Die Prüfansicht eines Bescheids: Rechenweg der Widerspruchsfrist,
|
||||
* Ampel und Statuswechsel.
|
||||
*
|
||||
* Der Rechenweg steht ausgeschrieben da, weil die Anwendung sich irren
|
||||
* kann. Wer eine Frist verpasst, soll sehen, wo die Rechnung abgebogen
|
||||
* ist - und nicht einer Zahl vertrauen müssen, die aus dem Nichts kommt.
|
||||
*/
|
||||
|
||||
export type Rechenweg = {
|
||||
berechenbar: boolean
|
||||
widerspruchMoeglichBis: string | null
|
||||
rechnerischesEnde: string | null
|
||||
verschoben: boolean
|
||||
tageVerbleibend: number | null
|
||||
abgelaufen: boolean
|
||||
ampel: { variante: 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'; text: string }
|
||||
schritte: { titel: string; text: string; rechtsgrundlage: string }[]
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
function alsAnzeige(iso: string | null): string {
|
||||
return iso ? iso.split('-').reverse().join('.') : '–'
|
||||
}
|
||||
|
||||
export function BescheidPruefung({
|
||||
bescheidId,
|
||||
pruefStatus,
|
||||
widerspruchEingelegtAm,
|
||||
rechenweg,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
bescheidId: string
|
||||
pruefStatus: BescheidPruefStatus
|
||||
widerspruchEingelegtAm: string
|
||||
rechenweg: Rechenweg
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(bescheidStatusSetzen, {})
|
||||
const [status, setStatus] = useState<BescheidPruefStatus>(pruefStatus)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Widerspruchsfrist</CardTitle>
|
||||
<CardDescription>
|
||||
Berechnet aus dem Zugangsdatum. Die Anwendung rechnet — entscheiden tun der Träger
|
||||
und im Streitfall das Sozialgericht.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={rechenweg.ampel.variante}>{rechenweg.ampel.text}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{rechenweg.berechenbar ? (
|
||||
<>
|
||||
<div className="rounded-lg border bg-muted/40 p-4">
|
||||
<p className="text-sm text-muted-foreground">Widerspruch möglich bis</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{alsAnzeige(rechenweg.widerspruchMoeglichBis)}
|
||||
</p>
|
||||
{rechenweg.verschoben && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Rechnerisch wäre es der {alsAnzeige(rechenweg.rechnerischesEnde)} gewesen —
|
||||
verschoben auf den nächsten Werktag.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ol className="space-y-3">
|
||||
{rechenweg.schritte.map((schritt, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
<span
|
||||
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{schritt.titel}</p>
|
||||
<p className="text-sm text-muted-foreground">{schritt.text}</p>
|
||||
<p className="mt-0.5 text-xs font-medium text-muted-foreground">
|
||||
{schritt.rechtsgrundlage}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
) : (
|
||||
<Alert variant="warnung">
|
||||
<Scale aria-hidden="true" />
|
||||
<AlertTitle>Frist noch nicht berechenbar</AlertTitle>
|
||||
<AlertDescription>
|
||||
Ohne Zugangsdatum lässt sich nichts rechnen. Trag oben ein, wann der Bescheid im
|
||||
Briefkasten lag.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rechenweg.hinweise.map((hinweis, i) => (
|
||||
<Alert key={i} variant="hinweis">
|
||||
<Scale aria-hidden="true" />
|
||||
<AlertDescription>{hinweis}</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 84 SGG" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Prüfstand</CardTitle>
|
||||
<CardDescription>{PRUEFSTATUS_ERKLAERUNG[pruefStatus]}</CardDescription>
|
||||
</div>
|
||||
<BescheidAbzeichen status={pruefStatus} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{darfSchreiben ? (
|
||||
<form action={aktion} className="space-y-4">
|
||||
<input type="hidden" name="bescheidId" value={bescheidId} />
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<Feld
|
||||
id="pruefStatus"
|
||||
beschriftung="Neuer Stand"
|
||||
fehler={zustand.felder?.pruefStatus}
|
||||
erklaerung={PRUEFSTATUS_ERKLAERUNG[status]}
|
||||
>
|
||||
<Select
|
||||
name="pruefStatus"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as BescheidPruefStatus)}
|
||||
>
|
||||
{PRUEFSTATUS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{PRUEFSTATUS_BESCHRIFTUNG[s]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
{status === 'WIDERSPRUCH_EINGELEGT' && (
|
||||
<Feld
|
||||
id="widerspruchEingelegtAm"
|
||||
beschriftung="Widerspruch eingelegt am"
|
||||
pflicht
|
||||
fehler={zustand.felder?.widerspruchEingelegtAm}
|
||||
erklaerung="Der Tag, an dem der Widerspruch beim Träger eingegangen ist — nicht der Tag, an dem du ihn geschrieben hast."
|
||||
>
|
||||
<Input
|
||||
name="widerspruchEingelegtAm"
|
||||
type="date"
|
||||
defaultValue={widerspruchEingelegtAm}
|
||||
/>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
<Absenden variant="outline">Stand übernehmen</Absenden>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zum Ändern des Prüfstands fehlt dir die Schreibberechtigung.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import type { SozialhilfeBewertung } from '@prisma/client'
|
||||
import { BookOpen, ChevronDown } from 'lucide-react'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import type { Argumentationshilfe } from '@/lib/sozialhilfe/schonvermoegen'
|
||||
import { ALLE_BEWERTUNGEN, BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_ERKLAERUNG } from '@/lib/sozialhilfe/schonvermoegen'
|
||||
import { bewertungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
|
||||
/**
|
||||
* Bewertung eines Vermögenspostens oder Vertrags im Sozialhilfeverfahren.
|
||||
*
|
||||
* Die Argumentationshilfen sind Textbausteine zum Übernehmen, keine
|
||||
* Automatik: Die Anwendung setzt nie selbst eine Bewertung. Sie schlägt
|
||||
* eine Richtung vor, liefert die Fundstelle und den Formulierungsvorschlag
|
||||
* - eingeordnet wird von Hand, und über die Anrechnung entscheidet
|
||||
* ohnehin der Träger.
|
||||
*/
|
||||
export function BewertungFormular({
|
||||
herkunft,
|
||||
id,
|
||||
bezeichnung,
|
||||
bewertung,
|
||||
begruendung,
|
||||
hilfen,
|
||||
darfSchreiben,
|
||||
beimSpeichern,
|
||||
}: {
|
||||
herkunft: 'VERMOEGEN' | 'VERTRAG'
|
||||
id: string
|
||||
bezeichnung: string
|
||||
bewertung: SozialhilfeBewertung
|
||||
begruendung: string
|
||||
hilfen: Argumentationshilfe[]
|
||||
darfSchreiben: boolean
|
||||
beimSpeichern?: () => void
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
|
||||
async (vorher, formular) => {
|
||||
const ergebnis = await bewertungSpeichern(vorher, formular)
|
||||
if (ergebnis.erfolg) beimSpeichern?.()
|
||||
return ergebnis
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
const [auswahl, setAuswahl] = useState<SozialhilfeBewertung>(bewertung)
|
||||
const [text, setText] = useState(begruendung)
|
||||
const [offeneHilfe, setOffeneHilfe] = useState<string | null>(null)
|
||||
|
||||
if (!darfSchreiben) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{BEWERTUNG_BESCHRIFTUNG[bewertung]}</span>
|
||||
</p>
|
||||
{begruendung ? (
|
||||
<p className="whitespace-pre-line text-sm text-muted-foreground">{begruendung}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Keine Begründung hinterlegt.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const uebernehmen = (hilfe: Argumentationshilfe) => {
|
||||
setText((vorher) => (vorher.trim() === '' ? hilfe.textbaustein : `${vorher}\n\n${hilfe.textbaustein}`))
|
||||
setAuswahl((vorher) => (vorher === 'UNGEPRUEFT' ? hilfe.vorschlag : vorher))
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4">
|
||||
<input type="hidden" name="herkunft" value={herkunft} />
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<Feld
|
||||
id={`bewertung-${id}`}
|
||||
beschriftung={`Einschätzung zu „${bezeichnung}“`}
|
||||
fehler={zustand.felder?.bewertung}
|
||||
erklaerung={BEWERTUNG_ERKLAERUNG[auswahl]}
|
||||
>
|
||||
<Select
|
||||
name="bewertung"
|
||||
value={auswahl}
|
||||
onChange={(e) => setAuswahl(e.target.value as SozialhilfeBewertung)}
|
||||
>
|
||||
{ALLE_BEWERTUNGEN.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{BEWERTUNG_BESCHRIFTUNG[b]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`begruendung-${id}`}
|
||||
beschriftung="Begründung"
|
||||
fehler={zustand.felder?.begruendung}
|
||||
erklaerung="Wird verschlüsselt gespeichert. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter — und in einem halben Jahr weißt du selbst nicht mehr, warum."
|
||||
>
|
||||
<Textarea
|
||||
name="begruendung"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={8}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
{hilfen.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2.5">
|
||||
<BookOpen className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="text-sm font-medium">Argumentationshilfen</span>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{hilfen.map((hilfe) => (
|
||||
<li key={hilfe.schluessel} className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setOffeneHilfe((v) => (v === hilfe.schluessel ? null : hilfe.schluessel))
|
||||
}
|
||||
aria-expanded={offeneHilfe === hilfe.schluessel}
|
||||
className="flex w-full items-start justify-between gap-3 text-left"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{hilfe.titel}</span>
|
||||
<span className="block text-sm text-muted-foreground">{hilfe.kurz}</span>
|
||||
<span className="mt-0.5 block text-xs font-medium text-muted-foreground">
|
||||
{hilfe.rechtsgrundlage}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`mt-1 size-4 shrink-0 transition-transform ${
|
||||
offeneHilfe === hilfe.schluessel ? 'rotate-180' : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{offeneHilfe === hilfe.schluessel && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="rounded-md border border-ampel-orange/40 bg-ampel-orange/10 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide">
|
||||
Worauf zu achten ist
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{hilfe.worauf_achten}</p>
|
||||
</div>
|
||||
<pre className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/50 p-3 font-sans text-sm">
|
||||
{hilfe.textbaustein}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => uebernehmen(hilfe)}
|
||||
>
|
||||
In die Begründung übernehmen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" />
|
||||
|
||||
<Absenden>Bewertung speichern</Absenden>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { ListChecks } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import type { Checklistenposition } from '@/lib/sozialhilfe/checkliste'
|
||||
import { checklisteUebernehmen, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
|
||||
/**
|
||||
* Die vorbefüllte Checkliste der Standardunterlagen.
|
||||
*
|
||||
* Zu jeder Position steht, warum der Träger sie will. Das ist der eigentliche
|
||||
* Wert dieser Liste: Wer den Zweck kennt, beschafft das Richtige beim ersten
|
||||
* Mal - und erkennt, wenn eine Nachforderung über das Nötige hinausgeht.
|
||||
*/
|
||||
export function ChecklistePanel({
|
||||
antragId,
|
||||
offen,
|
||||
bereitsUebernommen,
|
||||
}: {
|
||||
antragId: string
|
||||
offen: Checklistenposition[]
|
||||
bereitsUebernommen: number
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(checklisteUebernehmen, {})
|
||||
const [ausgewaehlt, setAusgewaehlt] = useState<Set<string>>(new Set())
|
||||
|
||||
const umschalten = (schluessel: string) => {
|
||||
setAusgewaehlt((vorher) => {
|
||||
const neu = new Set(vorher)
|
||||
if (neu.has(schluessel)) neu.delete(schluessel)
|
||||
else neu.add(schluessel)
|
||||
return neu
|
||||
})
|
||||
}
|
||||
|
||||
const alleWaehlen = () => {
|
||||
setAusgewaehlt((vorher) =>
|
||||
vorher.size === offen.length ? new Set() : new Set(offen.map((p) => p.schluessel)),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListChecks className="size-5" aria-hidden="true" />
|
||||
Standardunterlagen
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Diesen Stapel fordert der Bezirk bei einem Antrag auf Hilfe zur Pflege fast immer an. Wer
|
||||
ihn vorher beschafft, spart Wochen.{' '}
|
||||
{bereitsUebernommen > 0 && (
|
||||
<>
|
||||
{bereitsUebernommen} von {bereitsUebernommen + offen.length} stehen schon im Tracker.
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{offen.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Alle Standardunterlagen stehen bereits im Tracker. Weitere Anforderungen des Trägers
|
||||
trägst du dort einzeln nach.
|
||||
</p>
|
||||
) : (
|
||||
<form action={aktion} className="space-y-4">
|
||||
<input type="hidden" name="antragId" value={antragId} />
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={alleWaehlen}
|
||||
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{ausgewaehlt.size === offen.length ? 'Auswahl aufheben' : 'Alle auswählen'}
|
||||
</button>
|
||||
|
||||
<ul className="max-h-[28rem] space-y-2 overflow-y-auto rounded-lg border p-2">
|
||||
{offen.map((position) => (
|
||||
<li key={position.schluessel}>
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md p-2 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
name="schluessel"
|
||||
value={position.schluessel}
|
||||
checked={ausgewaehlt.has(position.schluessel)}
|
||||
onChange={() => umschalten(position.schluessel)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">{position.bezeichnung}</span>
|
||||
<span className="mt-0.5 block text-sm text-muted-foreground">
|
||||
{position.erlaeuterung}
|
||||
</span>
|
||||
{position.rechtsgrundlage && (
|
||||
<span className="mt-1 block text-xs font-medium text-muted-foreground">
|
||||
{position.rechtsgrundlage}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="checkliste-angefordertAm"
|
||||
beschriftung="Angefordert am"
|
||||
erklaerung="Datum des Anforderungsschreibens — gilt für alle ausgewählten Positionen."
|
||||
>
|
||||
<Input name="angefordertAm" type="date" />
|
||||
</Feld>
|
||||
<Feld
|
||||
id="checkliste-fristBis"
|
||||
beschriftung="Frist des Trägers"
|
||||
erklaerung="Optional. Kannst du später je Zeile anpassen."
|
||||
>
|
||||
<Input name="fristBis" type="date" />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Absenden disabled={ausgewaehlt.size === 0}>
|
||||
{ausgewaehlt.size === 0
|
||||
? 'Nichts ausgewählt'
|
||||
: `${ausgewaehlt.size} ${
|
||||
ausgewaehlt.size === 1 ? 'Unterlage' : 'Unterlagen'
|
||||
} übernehmen`}
|
||||
</Absenden>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 60 SGB I" kurz />
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { Feld, Feldgruppe } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
|
||||
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
|
||||
export type HeimkostenWerte = {
|
||||
jahr: number
|
||||
monat: number
|
||||
pflegeEigenanteil: string
|
||||
unterkunftVerpflegung: string
|
||||
investitionskosten: string
|
||||
ausbildungsumlage: string
|
||||
zusatzleistungen: string
|
||||
pflegekasse: string
|
||||
leistungszuschlagProzent: number
|
||||
leistungszuschlag: string
|
||||
rente: string
|
||||
sonstigeEinkuenfte: string
|
||||
sozialhilfe: string
|
||||
eigenmittel: string
|
||||
barbetrag: string
|
||||
bekleidungspauschale: string
|
||||
abwesenheitstage: number
|
||||
bemerkung: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Monat der Gegenüberstellung.
|
||||
*
|
||||
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
|
||||
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
|
||||
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
|
||||
* Stelle, an der es auf der Rechnung steht.
|
||||
*/
|
||||
export function HeimkostenFormular({
|
||||
werte,
|
||||
jahre,
|
||||
vorschlagPflegekasse,
|
||||
vorschlagBarbetrag,
|
||||
}: {
|
||||
werte: HeimkostenWerte
|
||||
jahre: number[]
|
||||
/** Pauschale der Pflegekasse zum hinterlegten Pflegegrad, als "1319,00". */
|
||||
vorschlagPflegekasse: { betrag: string; text: string } | null
|
||||
vorschlagBarbetrag: { betrag: string; text: string }
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
|
||||
const [pflegekasse, setPflegekasse] = useState(werte.pflegekasse)
|
||||
const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-6">
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monat erfassen</CardTitle>
|
||||
<CardDescription>
|
||||
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
|
||||
überschrieben.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
|
||||
<Select name="jahr" defaultValue={String(werte.jahr)}>
|
||||
{jahre.map((j) => (
|
||||
<option key={j} value={j}>
|
||||
{j}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
|
||||
<Select name="monat" defaultValue={String(werte.monat)}>
|
||||
{MONATSNAMEN.map((name, i) => (
|
||||
<option key={name} value={i + 1}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Kosten"
|
||||
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
|
||||
>
|
||||
<Feld
|
||||
id="pflegeEigenanteil"
|
||||
beschriftung="Einrichtungseinheitlicher Eigenanteil"
|
||||
fehler={zustand.felder?.pflegeEigenanteil}
|
||||
erklaerung="Der pflegebedingte Anteil (EEE). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
|
||||
>
|
||||
<Input
|
||||
name="pflegeEigenanteil"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.pflegeEigenanteil}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="unterkunftVerpflegung"
|
||||
beschriftung="Unterkunft und Verpflegung"
|
||||
fehler={zustand.felder?.unterkunftVerpflegung}
|
||||
>
|
||||
<Input
|
||||
name="unterkunftVerpflegung"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.unterkunftVerpflegung}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="investitionskosten"
|
||||
beschriftung="Investitionskosten"
|
||||
fehler={zustand.felder?.investitionskosten}
|
||||
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
|
||||
>
|
||||
<Input
|
||||
name="investitionskosten"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.investitionskosten}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="ausbildungsumlage"
|
||||
beschriftung="Ausbildungsumlage"
|
||||
fehler={zustand.felder?.ausbildungsumlage}
|
||||
>
|
||||
<Input
|
||||
name="ausbildungsumlage"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.ausbildungsumlage}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="zusatzleistungen"
|
||||
beschriftung="Zusatzleistungen"
|
||||
fehler={zustand.felder?.zusatzleistungen}
|
||||
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
|
||||
>
|
||||
<Input
|
||||
name="zusatzleistungen"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.zusatzleistungen}
|
||||
/>
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
|
||||
<Feld
|
||||
id="pflegekasse"
|
||||
beschriftung="Leistung der Pflegekasse"
|
||||
fehler={zustand.felder?.pflegekasse}
|
||||
erklaerung={
|
||||
vorschlagPflegekasse ? (
|
||||
<>
|
||||
{vorschlagPflegekasse.text}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPflegekasse(vorschlagPflegekasse.betrag)}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Wert übernehmen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
'Pauschale nach § 43 SGB XI. Hinterlege den Pflegegrad in den Stammdaten, dann steht hier ein Vorschlag.'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Input
|
||||
name="pflegekasse"
|
||||
inputMode="decimal"
|
||||
value={pflegekasse}
|
||||
onChange={(e) => setPflegekasse(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="leistungszuschlagProzent"
|
||||
beschriftung="Leistungszuschlag in Prozent"
|
||||
fehler={zustand.felder?.leistungszuschlagProzent}
|
||||
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
|
||||
>
|
||||
<Input
|
||||
name="leistungszuschlagProzent"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={werte.leistungszuschlagProzent}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="leistungszuschlag"
|
||||
beschriftung="Leistungszuschlag in Euro"
|
||||
fehler={zustand.felder?.leistungszuschlag}
|
||||
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
|
||||
>
|
||||
<Input
|
||||
name="leistungszuschlag"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.leistungszuschlag}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
|
||||
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="sonstigeEinkuenfte"
|
||||
beschriftung="Sonstige Einkünfte"
|
||||
fehler={zustand.felder?.sonstigeEinkuenfte}
|
||||
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
|
||||
>
|
||||
<Input
|
||||
name="sonstigeEinkuenfte"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.sonstigeEinkuenfte}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="sozialhilfe"
|
||||
beschriftung="Anteil der Sozialhilfe"
|
||||
fehler={zustand.felder?.sozialhilfe}
|
||||
erklaerung="Was der Bezirk laut Bescheid übernimmt."
|
||||
>
|
||||
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="eigenmittel"
|
||||
beschriftung="Eigenmittel"
|
||||
fehler={zustand.felder?.eigenmittel}
|
||||
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
|
||||
>
|
||||
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Was verbleiben muss"
|
||||
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
|
||||
>
|
||||
<Feld
|
||||
id="barbetrag"
|
||||
beschriftung="Barbetrag"
|
||||
fehler={zustand.felder?.barbetrag}
|
||||
erklaerung={
|
||||
<>
|
||||
{vorschlagBarbetrag.text}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Wert übernehmen
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
name="barbetrag"
|
||||
inputMode="decimal"
|
||||
value={barbetrag}
|
||||
onChange={(e) => setBarbetrag(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="bekleidungspauschale"
|
||||
beschriftung="Bekleidungspauschale"
|
||||
fehler={zustand.felder?.bekleidungspauschale}
|
||||
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
|
||||
>
|
||||
<Input
|
||||
name="bekleidungspauschale"
|
||||
inputMode="decimal"
|
||||
defaultValue={werte.bekleidungspauschale}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="abwesenheitstage"
|
||||
beschriftung="Abwesenheitstage"
|
||||
fehler={zustand.felder?.abwesenheitstage}
|
||||
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
|
||||
>
|
||||
<Input
|
||||
name="abwesenheitstage"
|
||||
type="number"
|
||||
min={0}
|
||||
max={31}
|
||||
defaultValue={werte.abwesenheitstage}
|
||||
/>
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
|
||||
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
|
||||
</Feld>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
|
||||
|
||||
<Absenden>Monat speichern</Absenden>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { formatCent } from '@/lib/utils'
|
||||
import { monatsname } from '@/lib/sozialhilfe/heimkosten'
|
||||
|
||||
/**
|
||||
* Jahresverlauf der Gegenüberstellung.
|
||||
*
|
||||
* Server Component: Die Beträge bleiben BigInt bis zur Anzeige, es geht
|
||||
* nichts über die Grenze zum Browser. Die Tabelle scrollt in ihrem eigenen
|
||||
* Rahmen - am Handy darf nie die ganze Seite seitlich wandern.
|
||||
*/
|
||||
|
||||
export type MonatsZeile = {
|
||||
jahr: number
|
||||
monat: number
|
||||
heimkostenCent: bigint
|
||||
gesamtbedarfCent: bigint
|
||||
deckungCent: bigint
|
||||
deckungslueckeCent: bigint
|
||||
ueberdeckungCent: bigint
|
||||
abwesenheitstage: number
|
||||
erfasst: boolean
|
||||
}
|
||||
|
||||
export function HeimkostenTabelle({ zeilen }: { zeilen: MonatsZeile[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Monat</TableHead>
|
||||
<TableHead className="text-right">Heimkosten</TableHead>
|
||||
<TableHead className="text-right">Gesamtbedarf</TableHead>
|
||||
<TableHead className="text-right">Deckung</TableHead>
|
||||
<TableHead className="text-right">Ergebnis</TableHead>
|
||||
<TableHead className="text-right">Abwesend</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{zeilen.map((zeile) => (
|
||||
<TableRow key={`${zeile.jahr}-${zeile.monat}`}>
|
||||
<TableCell className="font-medium">
|
||||
{monatsname(zeile.monat)}
|
||||
{!zeile.erfasst && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
nicht erfasst
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{zeile.erfasst ? formatCent(zeile.heimkostenCent) : '–'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{zeile.erfasst ? formatCent(zeile.gesamtbedarfCent) : '–'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{zeile.erfasst ? formatCent(zeile.deckungCent) : '–'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!zeile.erfasst ? (
|
||||
'–'
|
||||
) : zeile.deckungslueckeCent > 0n ? (
|
||||
<Badge variant="rot">Lücke {formatCent(zeile.deckungslueckeCent)}</Badge>
|
||||
) : zeile.ueberdeckungCent > 0n ? (
|
||||
<Badge variant="gruen">Überschuss {formatCent(zeile.ueberdeckungCent)}</Badge>
|
||||
) : (
|
||||
<Badge variant="gruen">gedeckt</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{zeile.erfasst ? zeile.abwesenheitstage : '–'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import type { LeistungStatus } from '@prisma/client'
|
||||
import { CalendarClock, Plus } from 'lucide-react'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import { LeistungAbzeichen } from '@/components/sozialhilfe/abzeichen'
|
||||
import type { Katalogleistung } from '@/lib/sozialhilfe/leistungskatalog'
|
||||
import { LEISTUNG_STATUS, LEISTUNG_STATUS_BESCHRIFTUNG } from '@/lib/sozialhilfe/leistungskatalog'
|
||||
import { formatCent, formatDatum } from '@/lib/utils'
|
||||
import { katalogUebernehmen, leistungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
|
||||
export type LeistungZeile = {
|
||||
id: string
|
||||
katalogSchluessel: string | null
|
||||
bezeichnung: string
|
||||
traeger: string
|
||||
rechtsgrundlage: string
|
||||
status: LeistungStatus
|
||||
beantragtAm: string | null
|
||||
bewilligtAm: string | null
|
||||
gueltigBis: string | null
|
||||
betragCent: number | null
|
||||
jaehrlichNeuBeantragen: boolean
|
||||
notizen: string
|
||||
/** Aus dem Katalog nachgeschlagen, für den Ausklapp-Bereich. */
|
||||
katalog: Katalogleistung | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Katalog weiterer Leistungen mit pflegbarem Status.
|
||||
*
|
||||
* Der Katalog steht daneben, damit man nicht raten muss, was man für den
|
||||
* Antrag braucht. Die jährlich zu wiederholenden Anträge tragen einen
|
||||
* eigenen Hinweis - sie sind es, die untergehen.
|
||||
*/
|
||||
export function LeistungenListe({
|
||||
leistungen,
|
||||
offeneKatalogeintraege,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
leistungen: LeistungZeile[]
|
||||
offeneKatalogeintraege: Katalogleistung[]
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [katalogZustand, katalogAktion] = useActionState<Formularzustand, FormData>(
|
||||
katalogUebernehmen,
|
||||
{},
|
||||
)
|
||||
const [offen, setOffen] = useState<string | null>(null)
|
||||
|
||||
const jaehrlich = leistungen.filter(
|
||||
(l) => l.jaehrlichNeuBeantragen && l.status !== 'NICHT_ZUTREFFEND',
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{jaehrlich.length > 0 && (
|
||||
<Alert variant="warnung">
|
||||
<CalendarClock aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
<span className="font-medium">Jährlich neu zu beantragen: </span>
|
||||
{jaehrlich.map((l) => l.bezeichnung).join(', ')}. Das bayerische Landespflegegeld
|
||||
verfällt ersatzlos, wenn der Antrag nicht bis zum 31.12. gestellt ist — leg dir dafür
|
||||
eine Frist an.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{offeneKatalogeintraege.length > 0 && darfSchreiben && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus className="size-5" aria-hidden="true" />
|
||||
Aus dem Katalog übernehmen
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Leistungen, die noch nicht in der Liste stehen. Übernommen wird mit Träger,
|
||||
Rechtsgrundlage und dem Hinweis, was man dafür braucht.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={katalogAktion} className="space-y-4">
|
||||
<Meldung zustand={katalogZustand} />
|
||||
<ul className="space-y-2">
|
||||
{offeneKatalogeintraege.map((k) => (
|
||||
<li key={k.katalogSchluessel}>
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/50">
|
||||
<Checkbox name="schluessel" value={k.katalogSchluessel} className="mt-1" />
|
||||
<span className="min-w-0">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{k.bezeichnung}</span>
|
||||
{k.jaehrlichNeuBeantragen && <Badge variant="gelb">jährlich</Badge>}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-sm text-muted-foreground">{k.kurz}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{k.traeger} · {k.rechtsgrundlage}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Absenden variant="outline">Ausgewählte übernehmen</Absenden>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{leistungen.map((l) => (
|
||||
<li key={l.id}>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 pt-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{l.bezeichnung}</span>
|
||||
<LeistungAbzeichen status={l.status} />
|
||||
{l.jaehrlichNeuBeantragen && <Badge variant="gelb">jährlich neu</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{l.traeger}
|
||||
{l.rechtsgrundlage ? ` · ${l.rechtsgrundlage}` : ''}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{l.betragCent !== null && (
|
||||
<span className="font-medium tabular-nums">{formatCent(l.betragCent)} </span>
|
||||
)}
|
||||
{l.beantragtAm && <>Beantragt am {formatDatum(l.beantragtAm)}. </>}
|
||||
{l.bewilligtAm && <>Bewilligt am {formatDatum(l.bewilligtAm)}. </>}
|
||||
{l.gueltigBis && <>Gültig bis {formatDatum(l.gueltigBis)}.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{darfSchreiben && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOffen((v) => (v === l.id ? null : l.id))}
|
||||
>
|
||||
{offen === l.id ? 'Zuklappen' : 'Bearbeiten'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{l.katalog && (
|
||||
<details className="rounded-md border bg-muted/30 p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Voraussetzungen und benötigte Unterlagen
|
||||
</summary>
|
||||
<p className="mt-2 text-sm">{l.katalog.voraussetzung}</p>
|
||||
{l.katalog.betragHinweis && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{l.katalog.betragHinweis}</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm font-medium">Dafür brauchst du:</p>
|
||||
<ul className="mt-1 list-inside list-disc text-sm text-muted-foreground">
|
||||
{l.katalog.benoetigt.map((b) => (
|
||||
<li key={b}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{offen === l.id && darfSchreiben && (
|
||||
<div className="border-t pt-4">
|
||||
<LeistungFormular zeile={l} beimSpeichern={() => setOffen(null)} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="SGB XII, SGB V, SGB IX, BayLPflGG" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LeistungFormular({
|
||||
zeile,
|
||||
beimSpeichern,
|
||||
}: {
|
||||
zeile: LeistungZeile
|
||||
beimSpeichern: () => void
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
|
||||
async (vorher, formular) => {
|
||||
const ergebnis = await leistungSpeichern(vorher, formular)
|
||||
if (ergebnis.erfolg) beimSpeichern()
|
||||
return ergebnis
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4">
|
||||
<input type="hidden" name="leistungId" value={zeile.id} />
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<Feld id={`bezeichnung-${zeile.id}`} beschriftung="Bezeichnung" pflicht fehler={zustand.felder?.bezeichnung}>
|
||||
<Input name="bezeichnung" defaultValue={zeile.bezeichnung} required />
|
||||
</Feld>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id={`traeger-${zeile.id}`} beschriftung="Träger" fehler={zustand.felder?.traeger}>
|
||||
<Input name="traeger" defaultValue={zeile.traeger} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`rechtsgrundlage-${zeile.id}`}
|
||||
beschriftung="Rechtsgrundlage"
|
||||
fehler={zustand.felder?.rechtsgrundlage}
|
||||
>
|
||||
<Input name="rechtsgrundlage" defaultValue={zeile.rechtsgrundlage} />
|
||||
</Feld>
|
||||
|
||||
<Feld id={`status-${zeile.id}`} beschriftung="Status" fehler={zustand.felder?.status}>
|
||||
<Select name="status" defaultValue={zeile.status}>
|
||||
{LEISTUNG_STATUS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{LEISTUNG_STATUS_BESCHRIFTUNG[s]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`betrag-${zeile.id}`}
|
||||
beschriftung="Betrag"
|
||||
fehler={zustand.felder?.betrag}
|
||||
erklaerung="z.B. 1.000,00 für das Landespflegegeld."
|
||||
>
|
||||
<Input
|
||||
name="betrag"
|
||||
inputMode="decimal"
|
||||
defaultValue={
|
||||
zeile.betragCent === null
|
||||
? ''
|
||||
: `${Math.trunc(zeile.betragCent / 100)},${String(Math.abs(zeile.betragCent % 100)).padStart(2, '0')}`
|
||||
}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`beantragtAm-${zeile.id}`}
|
||||
beschriftung="Beantragt am"
|
||||
fehler={zustand.felder?.beantragtAm}
|
||||
>
|
||||
<Input name="beantragtAm" type="date" defaultValue={zeile.beantragtAm ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`bewilligtAm-${zeile.id}`}
|
||||
beschriftung="Bewilligt am"
|
||||
fehler={zustand.felder?.bewilligtAm}
|
||||
>
|
||||
<Input name="bewilligtAm" type="date" defaultValue={zeile.bewilligtAm ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`gueltigBis-${zeile.id}`}
|
||||
beschriftung="Gültig bis"
|
||||
fehler={zustand.felder?.gueltigBis}
|
||||
erklaerung="Danach ist ein neuer Nachweis fällig."
|
||||
>
|
||||
<Input name="gueltigBis" type="date" defaultValue={zeile.gueltigBis ?? ''} />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<Checkbox
|
||||
name="jaehrlichNeuBeantragen"
|
||||
defaultChecked={zeile.jaehrlichNeuBeantragen}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>Muss jährlich neu beantragt werden</span>
|
||||
</label>
|
||||
|
||||
<Feld id={`notizen-${zeile.id}`} beschriftung="Notizen" fehler={zustand.felder?.notizen}>
|
||||
<Textarea name="notizen" defaultValue={zeile.notizen} rows={3} />
|
||||
</Feld>
|
||||
|
||||
<Absenden>Speichern</Absenden>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import type { NachforderungStatus } from '@prisma/client'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import {
|
||||
NACHFORDERUNG_BESCHRIFTUNG,
|
||||
NACHFORDERUNG_ERKLAERUNG,
|
||||
NACHFORDERUNG_STATUS,
|
||||
} from '@/lib/sozialhilfe/nachforderungen'
|
||||
import { nachforderungSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
|
||||
|
||||
export type NachforderungWerte = {
|
||||
id: string | null
|
||||
bezeichnung: string
|
||||
erlaeuterung: string
|
||||
status: NachforderungStatus
|
||||
angefordertAm: string
|
||||
fristBis: string
|
||||
uebersandtAm: string
|
||||
begruendungNichtVorhanden: string
|
||||
reihenfolge: number
|
||||
ausCheckliste: boolean
|
||||
}
|
||||
|
||||
export const LEERE_NACHFORDERUNG: NachforderungWerte = {
|
||||
id: null,
|
||||
bezeichnung: '',
|
||||
erlaeuterung: '',
|
||||
status: 'OFFEN',
|
||||
angefordertAm: '',
|
||||
fristBis: '',
|
||||
uebersandtAm: '',
|
||||
begruendungNichtVorhanden: '',
|
||||
reihenfolge: 0,
|
||||
ausCheckliste: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zeile des Trackers anlegen oder ändern.
|
||||
*
|
||||
* Das Begründungsfeld erscheint, sobald "Nicht vorhanden" gewählt ist.
|
||||
* Es ist dann Pflicht: Eine unbeantwortete Zeile sieht beim Träger wie
|
||||
* fehlende Mitwirkung aus (§ 60, § 66 SGB I), eine begründete Fehlanzeige
|
||||
* dagegen ist eine Antwort.
|
||||
*/
|
||||
export function NachforderungFormular({
|
||||
antragId,
|
||||
werte,
|
||||
beimSpeichern,
|
||||
}: {
|
||||
antragId: string
|
||||
werte: NachforderungWerte
|
||||
beimSpeichern?: () => void
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(
|
||||
async (vorher, formular) => {
|
||||
const ergebnis = await nachforderungSpeichern(vorher, formular)
|
||||
if (ergebnis.erfolg) beimSpeichern?.()
|
||||
return ergebnis
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
const [status, setStatus] = useState<NachforderungStatus>(werte.status)
|
||||
const [fristBis, setFristBis] = useState(werte.fristBis)
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4">
|
||||
<input type="hidden" name="antragId" value={antragId} />
|
||||
{werte.id && <input type="hidden" name="nachforderungId" value={werte.id} />}
|
||||
<input type="hidden" name="reihenfolge" value={werte.reihenfolge} />
|
||||
|
||||
<Meldung zustand={zustand} />
|
||||
|
||||
<Feld
|
||||
id={`bezeichnung-${werte.id ?? 'neu'}`}
|
||||
beschriftung="Welche Unterlage?"
|
||||
pflicht
|
||||
fehler={zustand.felder?.bezeichnung}
|
||||
>
|
||||
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`erlaeuterung-${werte.id ?? 'neu'}`}
|
||||
beschriftung="Erläuterung"
|
||||
fehler={zustand.felder?.erlaeuterung}
|
||||
erklaerung="Wozu braucht der Träger das? Wer den Zweck notiert, kann Rückfragen vorwegnehmen."
|
||||
>
|
||||
<Textarea name="erlaeuterung" defaultValue={werte.erlaeuterung} rows={3} />
|
||||
</Feld>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id={`status-${werte.id ?? 'neu'}`}
|
||||
beschriftung="Status"
|
||||
fehler={zustand.felder?.status}
|
||||
erklaerung={NACHFORDERUNG_ERKLAERUNG[status]}
|
||||
>
|
||||
<Select
|
||||
name="status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as NachforderungStatus)}
|
||||
>
|
||||
{NACHFORDERUNG_STATUS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{NACHFORDERUNG_BESCHRIFTUNG[s]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`angefordertAm-${werte.id ?? 'neu'}`}
|
||||
beschriftung="Angefordert am"
|
||||
fehler={zustand.felder?.angefordertAm}
|
||||
erklaerung="Datum des Schreibens vom Träger."
|
||||
>
|
||||
<Input name="angefordertAm" type="date" defaultValue={werte.angefordertAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`fristBis-${werte.id ?? 'neu'}`}
|
||||
beschriftung="Frist des Trägers"
|
||||
fehler={zustand.felder?.fristBis}
|
||||
>
|
||||
<Input
|
||||
name="fristBis"
|
||||
type="date"
|
||||
value={fristBis}
|
||||
onChange={(e) => setFristBis(e.target.value)}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`uebersandtAm-${werte.id ?? 'neu'}`}
|
||||
beschriftung="Übersandt am"
|
||||
fehler={zustand.felder?.uebersandtAm}
|
||||
erklaerung="Der Nachweis der Mitwirkung, falls der Träger später von einer Lücke ausgeht."
|
||||
>
|
||||
<Input name="uebersandtAm" type="date" defaultValue={werte.uebersandtAm} />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
{status === 'NICHT_VORHANDEN' && (
|
||||
<Feld
|
||||
id={`begruendung-${werte.id ?? 'neu'}`}
|
||||
beschriftung="Warum gibt es die Unterlage nicht?"
|
||||
pflicht
|
||||
fehler={zustand.felder?.begruendungNichtVorhanden}
|
||||
erklaerung="Dieser Text geht mit ins Unterlagenpaket. Beispiel: „Ein Mietvertrag existiert nicht, die Wohnung wurde 1978 als Eigentum erworben.“"
|
||||
>
|
||||
<Textarea
|
||||
name="begruendungNichtVorhanden"
|
||||
defaultValue={werte.begruendungNichtVorhanden}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
{fristBis !== '' && (
|
||||
<label className="flex items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
|
||||
<Checkbox name="fristAnlegen" className="mt-0.5" />
|
||||
<span>
|
||||
Frist im Fristenmodul anlegen.
|
||||
<span className="mt-0.5 block text-muted-foreground">
|
||||
Legt eine behördliche Frist zum {fristBis.split('-').reverse().join('.')} an, mit
|
||||
Vorwarnung 14, 7, 3 und 1 Tag vorher.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<Absenden>{werte.id ? 'Änderungen speichern' : 'Nachforderung aufnehmen'}</Absenden>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import type { NachforderungStatus } from '@prisma/client'
|
||||
import { CalendarClock, FileText, Pencil, Plus, Trash2 } 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 { Fortschritt } from '@/components/ui/fortschritt'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { AktionMitRueckfrage, Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
|
||||
import { NachforderungAbzeichen } from '@/components/sozialhilfe/abzeichen'
|
||||
import {
|
||||
LEERE_NACHFORDERUNG,
|
||||
NachforderungFormular,
|
||||
type NachforderungWerte,
|
||||
} from '@/components/sozialhilfe/nachforderung-formular'
|
||||
import { fristAmpel, trackerFortschritt } from '@/lib/sozialhilfe/nachforderungen'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import {
|
||||
nachforderungLoeschen,
|
||||
nachforderungStatusSetzen,
|
||||
type Formularzustand,
|
||||
} from '@/server/actions/sozialhilfe'
|
||||
|
||||
/**
|
||||
* Der Nachforderungs-Tracker.
|
||||
*
|
||||
* Solange auch nur eine Zeile offen ist, ruht das Verfahren. Deshalb steht
|
||||
* der Balken oben und die überfälligen Zeilen darüber - nicht als
|
||||
* Statistik, sondern als Arbeitsliste.
|
||||
*
|
||||
* Alle Datumswerte kommen als ISO-Zeichenkette aus der Server Component;
|
||||
* Date-Objekte und BigInt überstehen die Grenze zum Browser nicht.
|
||||
*/
|
||||
|
||||
export type TrackerEintrag = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
erlaeuterung: string | null
|
||||
status: NachforderungStatus
|
||||
angefordertAm: string | null
|
||||
fristBis: string | null
|
||||
uebersandtAm: string | null
|
||||
begruendungNichtVorhanden: string | null
|
||||
reihenfolge: number
|
||||
ausCheckliste: boolean
|
||||
anzahlDokumente: number
|
||||
}
|
||||
|
||||
function alsDatum(iso: string | null): Date | null {
|
||||
return iso ? new Date(`${iso}T00:00:00.000Z`) : null
|
||||
}
|
||||
|
||||
export function NachforderungTracker({
|
||||
antragId,
|
||||
eintraege,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
antragId: string
|
||||
eintraege: TrackerEintrag[]
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [neuOffen, setNeuOffen] = useState(false)
|
||||
|
||||
const fortschritt = trackerFortschritt(
|
||||
eintraege.map((e) => ({
|
||||
id: e.id,
|
||||
bezeichnung: e.bezeichnung,
|
||||
status: e.status,
|
||||
fristBis: alsDatum(e.fristBis),
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Nachforderungen</CardTitle>
|
||||
<CardDescription>
|
||||
Jede vom Träger angeforderte Unterlage als eigene Zeile. Auch eine begründete
|
||||
Fehlanzeige zählt als geliefert — sie ist eine Antwort.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{darfSchreiben && (
|
||||
<Button variant="outline" size="sm" onClick={() => setNeuOffen((o) => !o)}>
|
||||
<Plus aria-hidden="true" />
|
||||
{neuOffen ? 'Abbrechen' : 'Nachforderung'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Fortschritt
|
||||
className="pt-2"
|
||||
wert={fortschritt.geliefert}
|
||||
gesamt={fortschritt.gesamt}
|
||||
beschriftung={fortschritt.beschriftung}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{fortschritt.ueberfaellig.length > 0 && (
|
||||
<Alert variant="warnung">
|
||||
<CalendarClock aria-hidden="true" />
|
||||
<AlertTitle>
|
||||
{fortschritt.ueberfaellig.length}{' '}
|
||||
{fortschritt.ueberfaellig.length === 1 ? 'Unterlage ist' : 'Unterlagen sind'}{' '}
|
||||
überfällig
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{fortschritt.ueberfaellig.map((z) => z.bezeichnung).join(', ')}. Wenn du sie nicht
|
||||
beschaffen kannst, melde das dem Träger mit Begründung — Schweigen wird als fehlende
|
||||
Mitwirkung gewertet (§ 66 SGB I).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{neuOffen && darfSchreiben && (
|
||||
<div className="rounded-lg border bg-muted/30 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Neue Nachforderung</h3>
|
||||
<NachforderungFormular
|
||||
antragId={antragId}
|
||||
werte={{ ...LEERE_NACHFORDERUNG, reihenfolge: (eintraege.length + 1) * 10 }}
|
||||
beimSpeichern={() => setNeuOffen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eintraege.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Noch keine Nachforderungen erfasst"
|
||||
beschreibung="Übernimm die Standardunterlagen aus der Checkliste — dann steht der übliche Stapel des Bezirks schon hier, bevor er ihn anfordert."
|
||||
icon={FileText}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{eintraege.map((eintrag) => (
|
||||
<TrackerZeile
|
||||
key={eintrag.id}
|
||||
antragId={antragId}
|
||||
eintrag={eintrag}
|
||||
darfSchreiben={darfSchreiben}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 60 SGB I" kurz />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function TrackerZeile({
|
||||
antragId,
|
||||
eintrag,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
antragId: string
|
||||
eintrag: TrackerEintrag
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false)
|
||||
const [zustand, statusSetzen] = useActionState<Formularzustand, FormData>(
|
||||
nachforderungStatusSetzen,
|
||||
{},
|
||||
)
|
||||
|
||||
const ampel = fristAmpel(alsDatum(eintrag.fristBis), eintrag.status)
|
||||
|
||||
return (
|
||||
<li className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{eintrag.bezeichnung}</span>
|
||||
<NachforderungAbzeichen status={eintrag.status} />
|
||||
{eintrag.fristBis && <Badge variant={ampel.variante}>{ampel.text}</Badge>}
|
||||
{eintrag.ausCheckliste && <Badge variant="outline">aus Checkliste</Badge>}
|
||||
{eintrag.anzahlDokumente > 0 && (
|
||||
<Badge variant="outline">
|
||||
<FileText className="size-3" aria-hidden="true" />
|
||||
{eintrag.anzahlDokumente}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eintrag.erlaeuterung && (
|
||||
<p className="text-sm text-muted-foreground">{eintrag.erlaeuterung}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{eintrag.angefordertAm && <>Angefordert am {formatDatum(eintrag.angefordertAm)}. </>}
|
||||
{eintrag.fristBis && <>Frist bis {formatDatum(eintrag.fristBis)}. </>}
|
||||
{eintrag.uebersandtAm && <>Übersandt am {formatDatum(eintrag.uebersandtAm)}.</>}
|
||||
</p>
|
||||
|
||||
{eintrag.status === 'NICHT_VORHANDEN' && eintrag.begruendungNichtVorhanden && (
|
||||
<p className="rounded-md border border-border bg-muted/50 p-2 text-sm">
|
||||
<span className="font-medium">Begründung für den Träger: </span>
|
||||
{eintrag.begruendungNichtVorhanden}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{darfSchreiben && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||||
{eintrag.status === 'OFFEN' && (
|
||||
<SchnellStatus
|
||||
aktion={statusSetzen}
|
||||
nachforderungId={eintrag.id}
|
||||
ziel="IN_BESCHAFFUNG"
|
||||
text="In Beschaffung"
|
||||
/>
|
||||
)}
|
||||
{(eintrag.status === 'OFFEN' || eintrag.status === 'IN_BESCHAFFUNG') && (
|
||||
<SchnellStatus
|
||||
aktion={statusSetzen}
|
||||
nachforderungId={eintrag.id}
|
||||
ziel="UEBERSANDT"
|
||||
text="Übersandt"
|
||||
/>
|
||||
)}
|
||||
{eintrag.status === 'UEBERSANDT' && (
|
||||
<SchnellStatus
|
||||
aktion={statusSetzen}
|
||||
nachforderungId={eintrag.id}
|
||||
ziel="ANERKANNT"
|
||||
text="Anerkannt"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => setOffen((o) => !o)}>
|
||||
<Pencil aria-hidden="true" />
|
||||
<span className="sr-only sm:not-sr-only">{offen ? 'Zuklappen' : 'Bearbeiten'}</span>
|
||||
</Button>
|
||||
|
||||
<AktionMitRueckfrage
|
||||
aktion={nachforderungLoeschen.bind(null, eintrag.id)}
|
||||
frage={`„${eintrag.bezeichnung}“ in den Papierkorb legen?`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
<span className="sr-only">Löschen</span>
|
||||
</AktionMitRueckfrage>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Meldung zustand={zustand} />
|
||||
</div>
|
||||
|
||||
{offen && darfSchreiben && (
|
||||
<div className="mt-4 rounded-lg border bg-muted/30 p-4">
|
||||
<NachforderungFormular
|
||||
antragId={antragId}
|
||||
werte={{
|
||||
id: eintrag.id,
|
||||
bezeichnung: eintrag.bezeichnung,
|
||||
erlaeuterung: eintrag.erlaeuterung ?? '',
|
||||
status: eintrag.status,
|
||||
angefordertAm: eintrag.angefordertAm ?? '',
|
||||
fristBis: eintrag.fristBis ?? '',
|
||||
uebersandtAm: eintrag.uebersandtAm ?? '',
|
||||
begruendungNichtVorhanden: eintrag.begruendungNichtVorhanden ?? '',
|
||||
reihenfolge: eintrag.reihenfolge,
|
||||
ausCheckliste: eintrag.ausCheckliste,
|
||||
}}
|
||||
beimSpeichern={() => setOffen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Ein-Klick-Statuswechsel für die Fälle, die keine weitere Angabe brauchen. */
|
||||
function SchnellStatus({
|
||||
aktion,
|
||||
nachforderungId,
|
||||
ziel,
|
||||
text,
|
||||
}: {
|
||||
aktion: (formular: FormData) => void
|
||||
nachforderungId: string
|
||||
ziel: NachforderungStatus
|
||||
text: string
|
||||
}) {
|
||||
return (
|
||||
<form action={aktion}>
|
||||
<input type="hidden" name="nachforderungId" value={nachforderungId} />
|
||||
<input type="hidden" name="status" value={ziel} />
|
||||
<Absenden variant="outline" size="sm" laedtText="…">
|
||||
{text}
|
||||
</Absenden>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { SozialhilfeBewertung, VermoegensArt, VertragArt } from '@prisma/client'
|
||||
import { Coins, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { BewertungAbzeichen } from '@/components/sozialhilfe/abzeichen'
|
||||
import { BewertungFormular } from '@/components/sozialhilfe/bewertung-formular'
|
||||
import {
|
||||
ALLE_BEWERTUNGEN,
|
||||
BEWERTUNG_BESCHRIFTUNG,
|
||||
hilfenFuerVermoegensart,
|
||||
hilfenFuerVertragsart,
|
||||
} from '@/lib/sozialhilfe/schonvermoegen'
|
||||
import { formatCent, formatDatum } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Liste der zu bewertenden Posten mit Filter.
|
||||
*
|
||||
* Beträge kommen als Number in Cent herüber - BigInt überlebt die Grenze
|
||||
* zwischen Server und Browser nicht. Angezeigt wird über formatCent(),
|
||||
* gerechnet wird auf dem Server.
|
||||
*/
|
||||
|
||||
export type ListenPosten = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
herkunft: 'VERMOEGEN' | 'VERTRAG'
|
||||
art: string
|
||||
artBeschriftung: string
|
||||
bewertung: SozialhilfeBewertung
|
||||
wertCent: number | null
|
||||
wertStichtag: string | null
|
||||
begruendung: string
|
||||
zusatz: string | null
|
||||
}
|
||||
|
||||
export function Vermoegensliste({
|
||||
posten,
|
||||
darfSchreiben,
|
||||
}: {
|
||||
posten: ListenPosten[]
|
||||
darfSchreiben: boolean
|
||||
}) {
|
||||
const [filter, setFilter] = useState<SozialhilfeBewertung | 'ALLE'>('ALLE')
|
||||
const [offen, setOffen] = useState<string | null>(null)
|
||||
|
||||
const sichtbar = filter === 'ALLE' ? posten : posten.filter((p) => p.bewertung === filter)
|
||||
|
||||
const zaehler = (b: SozialhilfeBewertung) => posten.filter((p) => p.bewertung === b).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Bewertung filtern">
|
||||
<FilterKnopf aktiv={filter === 'ALLE'} onClick={() => setFilter('ALLE')}>
|
||||
Alle ({posten.length})
|
||||
</FilterKnopf>
|
||||
{ALLE_BEWERTUNGEN.map((b) => (
|
||||
<FilterKnopf key={b} aktiv={filter === b} onClick={() => setFilter(b)}>
|
||||
{BEWERTUNG_BESCHRIFTUNG[b]} ({zaehler(b)})
|
||||
</FilterKnopf>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sichtbar.length === 0 ? (
|
||||
<Leerzustand
|
||||
titel="Keine Posten in dieser Ansicht"
|
||||
beschreibung={
|
||||
posten.length === 0
|
||||
? 'Vermögensposten und Verträge legst du in den Modulen „Vermögen & Kasse“ und „Verträge“ an. Hier bewertest du sie für das Sozialamt.'
|
||||
: 'Wähle einen anderen Filter, um die übrigen Posten zu sehen.'
|
||||
}
|
||||
icon={Coins}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{sichtbar.map((p) => (
|
||||
<li key={`${p.herkunft}-${p.id}`}>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 pt-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{p.bezeichnung}</span>
|
||||
<BewertungAbzeichen bewertung={p.bewertung} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{p.artBeschriftung}
|
||||
{p.zusatz ? ` · ${p.zusatz}` : ''}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium tabular-nums">
|
||||
{p.wertCent === null ? 'Wert noch nicht ermittelt' : formatCent(p.wertCent)}
|
||||
</span>
|
||||
{p.wertStichtag && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
(Stand {formatDatum(p.wertStichtag)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setOffen((v) => (v === `${p.herkunft}-${p.id}` ? null : `${p.herkunft}-${p.id}`))
|
||||
}
|
||||
>
|
||||
<Pencil aria-hidden="true" />
|
||||
{offen === `${p.herkunft}-${p.id}` ? 'Zuklappen' : 'Bewerten'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{p.begruendung && offen !== `${p.herkunft}-${p.id}` && (
|
||||
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
|
||||
{p.begruendung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!p.begruendung && p.bewertung !== 'UNGEPRUEFT' && (
|
||||
<p className="text-sm text-ampel-orange">
|
||||
Bewertet, aber ohne Begründung. Im Widerspruchsverfahren zählt nur, was
|
||||
begründet ist.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{offen === `${p.herkunft}-${p.id}` && (
|
||||
<div className="border-t pt-4">
|
||||
<BewertungFormular
|
||||
herkunft={p.herkunft}
|
||||
id={p.id}
|
||||
bezeichnung={p.bezeichnung}
|
||||
bewertung={p.bewertung}
|
||||
begruendung={p.begruendung}
|
||||
darfSchreiben={darfSchreiben}
|
||||
hilfen={
|
||||
p.herkunft === 'VERMOEGEN'
|
||||
? hilfenFuerVermoegensart(p.art as VermoegensArt)
|
||||
: hilfenFuerVertragsart(p.art as VertragArt)
|
||||
}
|
||||
beimSpeichern={() => setOffen(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterKnopf({
|
||||
aktiv,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
aktiv: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Button variant={aktiv ? 'default' : 'outline'} size="sm" onClick={onClick} aria-pressed={aktiv}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, CheckCircle2, Copy, Loader2, Save } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
/**
|
||||
* Gemeinsame Formularbausteine der Etappe 6 (Immobilie, Verträge, Pflege).
|
||||
*
|
||||
* Sie stehen an einer Stelle, damit Absendeknopf, Fehler- und
|
||||
* Erfolgsmeldung in allen drei Modulen gleich aussehen und sich gleich
|
||||
* verhalten. Wer täglich zwischen den Seiten wechselt, soll nicht dreimal
|
||||
* dieselbe Sache neu lernen.
|
||||
*/
|
||||
|
||||
export function Absendeknopf({
|
||||
children = 'Speichern',
|
||||
className,
|
||||
variant,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'destructive' | 'ghost'
|
||||
}) {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<Button type="submit" disabled={pending} className={className} variant={variant}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
Wird gespeichert …
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save aria-hidden="true" />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Zeigt Fehler und Erfolg eines Formularzustands - beides mit Text, nie nur farbig. */
|
||||
export function Zustandsmeldung({ zustand }: { zustand: Formularzustand }) {
|
||||
if (zustand.fehler) {
|
||||
return (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
if (zustand.erfolg) {
|
||||
return (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.erfolg}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Textbaustein zum Kopieren.
|
||||
*
|
||||
* Bewusst als bearbeitbares Textfeld und nicht als fertiges PDF: Jeder
|
||||
* Fall hat eine Besonderheit, die in den Brief gehört. Ein Text, den man
|
||||
* nicht mehr ändern kann, wird früher oder später falsch abgeschickt.
|
||||
*/
|
||||
export function Textbaustein({
|
||||
text,
|
||||
beschriftung,
|
||||
hoehe = 'min-h-96',
|
||||
}: {
|
||||
text: string
|
||||
beschriftung: string
|
||||
hoehe?: string
|
||||
}) {
|
||||
const [inhalt, setInhalt] = React.useState(text)
|
||||
const [kopiert, setKopiert] = React.useState(false)
|
||||
|
||||
async function kopieren() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inhalt)
|
||||
setKopiert(true)
|
||||
window.setTimeout(() => setKopiert(false), 3000)
|
||||
} catch {
|
||||
// Ohne Zwischenablage-Erlaubnis bleibt der Text markierbar - das
|
||||
// genügt, eine Fehlermeldung wäre hier nur im Weg.
|
||||
setKopiert(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label htmlFor="textbaustein" className="text-sm font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={kopieren}>
|
||||
{kopiert ? (
|
||||
<>
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
Kopiert
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy aria-hidden="true" />
|
||||
In die Zwischenablage
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
id="textbaustein"
|
||||
value={inhalt}
|
||||
onChange={(e) => setInhalt(e.target.value)}
|
||||
spellCheck
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm leading-relaxed',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
hoehe,
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Der Text ist ein Entwurf. Lies ihn durch, ergänze die Besonderheiten deines Falls und
|
||||
schick ihn erst dann ab. Die PDF-Erzeugung kommt in einer späteren Ausbaustufe.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Zwei Werte nebeneinander - die Bauform der Stammdatenblätter. */
|
||||
export function Datenzeile({
|
||||
beschriftung,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
beschriftung: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-baseline justify-between gap-2 py-2', className)}>
|
||||
<dt className="text-sm text-muted-foreground">{beschriftung}</dt>
|
||||
<dd className="text-right font-medium tabular-nums">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld, Feldgruppe } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
|
||||
import {
|
||||
BEWERTUNG_BESCHRIFTUNG,
|
||||
BEWERTUNG_ERKLAERUNG,
|
||||
RHYTHMUS_BESCHRIFTUNG,
|
||||
VERTRAG_ART_BESCHRIFTUNG,
|
||||
VERTRAG_STATUS_BESCHRIFTUNG,
|
||||
} from '@/lib/vertraege/konstanten'
|
||||
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
|
||||
import { vertragSpeichern } from '@/server/actions/vertraege'
|
||||
import type { Formularzustand } from '@/lib/validierung/substanz'
|
||||
|
||||
export type VertragWerte = {
|
||||
id?: string
|
||||
bezeichnung: string
|
||||
gesellschaft: string
|
||||
art: string
|
||||
status: string
|
||||
vertragsnummer: string
|
||||
beginnAm: string
|
||||
endeAm: string
|
||||
laufzeitJahre: string
|
||||
kuendigungsfristMonate: string
|
||||
kuendigungZum: string
|
||||
gekuendigtAm: string
|
||||
beitrag: string
|
||||
rhythmus: string
|
||||
bewertungSozialhilfe: string
|
||||
bewertungBegruendung: string
|
||||
notizen: string
|
||||
}
|
||||
|
||||
export const LEERER_VERTRAG: VertragWerte = {
|
||||
bezeichnung: '',
|
||||
gesellschaft: '',
|
||||
art: 'LEBENSVERSICHERUNG',
|
||||
status: 'AKTIV',
|
||||
vertragsnummer: '',
|
||||
beginnAm: '',
|
||||
endeAm: '',
|
||||
laufzeitJahre: '',
|
||||
kuendigungsfristMonate: '3',
|
||||
kuendigungZum: '',
|
||||
gekuendigtAm: '',
|
||||
beitrag: '',
|
||||
rhythmus: 'MONATLICH',
|
||||
bewertungSozialhilfe: 'UNGEPRUEFT',
|
||||
bewertungBegruendung: '',
|
||||
notizen: '',
|
||||
}
|
||||
|
||||
export function VertragFormular({
|
||||
betreuteId,
|
||||
werte,
|
||||
}: {
|
||||
betreuteId: string
|
||||
werte: VertragWerte
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
|
||||
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
|
||||
const istNeu = !werte.id
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-8" noValidate>
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
|
||||
|
||||
<Zustandsmeldung zustand={zustand} />
|
||||
|
||||
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
|
||||
<Feld
|
||||
id="bezeichnung"
|
||||
beschriftung="Bezeichnung"
|
||||
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
|
||||
fehler={zustand.felder?.bezeichnung}
|
||||
pflicht
|
||||
>
|
||||
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
|
||||
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
|
||||
<Select name="art" defaultValue={werte.art}>
|
||||
{VERTRAG_ARTEN.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
|
||||
<Select name="status" defaultValue={werte.status}>
|
||||
{VERTRAG_STATUS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="vertragsnummer"
|
||||
beschriftung="Vertragsnummer"
|
||||
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
|
||||
fehler={zustand.felder?.vertragsnummer}
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Laufzeit und Kündigung"
|
||||
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
|
||||
>
|
||||
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
|
||||
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="endeAm"
|
||||
beschriftung="Vereinbartes Ende"
|
||||
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung."
|
||||
fehler={zustand.felder?.endeAm}
|
||||
>
|
||||
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="laufzeitJahre"
|
||||
beschriftung="Laufzeit in Jahren"
|
||||
erklaerung="Um wie viel verlängert sich der Vertrag? Leer lassen für den Regelfall von einem Jahr."
|
||||
fehler={zustand.felder?.laufzeitJahre}
|
||||
>
|
||||
<Input name="laufzeitJahre" inputMode="numeric" defaultValue={werte.laufzeitJahre} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="kuendigungsfristMonate"
|
||||
beschriftung="Kündigungsfrist in Monaten"
|
||||
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
|
||||
fehler={zustand.felder?.kuendigungsfristMonate}
|
||||
>
|
||||
<Input
|
||||
name="kuendigungsfristMonate"
|
||||
inputMode="numeric"
|
||||
defaultValue={werte.kuendigungsfristMonate}
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="kuendigungZum"
|
||||
beschriftung="Gekündigt zum"
|
||||
fehler={zustand.felder?.kuendigungZum}
|
||||
>
|
||||
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
|
||||
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
|
||||
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
|
||||
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
|
||||
</Feld>
|
||||
|
||||
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
|
||||
<Select name="rhythmus" defaultValue={werte.rhythmus}>
|
||||
<option value="">— nicht angegeben —</option>
|
||||
{ZAHL_RHYTHMEN.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{RHYTHMUS_BESCHRIFTUNG[r]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Bewertung fürs Sozialamt"
|
||||
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
|
||||
>
|
||||
<Feld
|
||||
id="bewertungSozialhilfe"
|
||||
beschriftung="Einordnung"
|
||||
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
|
||||
fehler={zustand.felder?.bewertungSozialhilfe}
|
||||
>
|
||||
<Select
|
||||
name="bewertungSozialhilfe"
|
||||
value={bewertung}
|
||||
onChange={(e) => setBewertung(e.target.value)}
|
||||
>
|
||||
{BEWERTUNGEN.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{BEWERTUNG_BESCHRIFTUNG[b]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="bewertungBegruendung"
|
||||
beschriftung="Begründung"
|
||||
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
|
||||
fehler={zustand.felder?.bewertungBegruendung}
|
||||
pflicht={bewertung !== 'UNGEPRUEFT'}
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Textarea
|
||||
name="bewertungBegruendung"
|
||||
defaultValue={werte.bewertungBegruendung}
|
||||
rows={4}
|
||||
maxLength={4000}
|
||||
/>
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feld
|
||||
id="notizen"
|
||||
beschriftung="Notizen"
|
||||
erklaerung="Wird verschlüsselt gespeichert."
|
||||
fehler={zustand.felder?.notizen}
|
||||
>
|
||||
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
|
||||
</Feld>
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
|
||||
|
||||
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'server-only'
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
Document,
|
||||
Footer,
|
||||
Header,
|
||||
HeadingLevel,
|
||||
PageNumber,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
convertMillimetersToTwip,
|
||||
} from 'docx'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht'
|
||||
|
||||
/**
|
||||
* DOCX-Ausgabe.
|
||||
*
|
||||
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
|
||||
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
|
||||
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
|
||||
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
|
||||
* .docx-Datei schon - in Word, LibreOffice oder Google Docs.
|
||||
*
|
||||
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
|
||||
* Arbeitsfassung.
|
||||
*
|
||||
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
|
||||
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
|
||||
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
|
||||
* dieselben, ein Berichtskopf passt ohne Umbau hinein.
|
||||
*/
|
||||
|
||||
export type DocxKopf = {
|
||||
titel: string
|
||||
betreute: { name: string; geburtsdatum: Date | null }
|
||||
behoerde?: string
|
||||
aktenzeichen?: string
|
||||
zeitraumVon?: Date | null
|
||||
zeitraumBis?: Date | null
|
||||
stichtag?: Date | null
|
||||
rechtsgrundlage?: string
|
||||
erstelltAm: Date
|
||||
erstelltVon: string
|
||||
}
|
||||
|
||||
const SCHRIFT = 'Arial'
|
||||
|
||||
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
|
||||
return new TextRun({
|
||||
text,
|
||||
bold: optionen.fett ?? false,
|
||||
color: optionen.grau ? '555555' : '000000',
|
||||
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
|
||||
font: SCHRIFT,
|
||||
})
|
||||
}
|
||||
|
||||
function kopfzeilen(kopf: DocxKopf): string[] {
|
||||
const zeilen: string[] = [
|
||||
`Betreute Person: ${kopf.betreute.name}` +
|
||||
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
|
||||
]
|
||||
if (kopf.behoerde) zeilen.push(kopf.behoerde)
|
||||
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
|
||||
if (kopf.zeitraumVon || kopf.zeitraumBis) {
|
||||
zeilen.push(
|
||||
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
|
||||
)
|
||||
}
|
||||
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
|
||||
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
|
||||
return zeilen
|
||||
}
|
||||
|
||||
function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
|
||||
return [
|
||||
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
|
||||
new Paragraph({
|
||||
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
|
||||
spacing: { after: 500 },
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [zeile('')],
|
||||
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
|
||||
spacing: { after: 60 },
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
|
||||
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
|
||||
* Aufzaehlungszeichen - dieselbe Regel wie im PDF.
|
||||
*/
|
||||
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
|
||||
const abschnitte = textZuAbschnitten(text)
|
||||
|
||||
const inhalt: Paragraph[] = [
|
||||
new Paragraph({
|
||||
children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
|
||||
spacing: { after: 160 },
|
||||
}),
|
||||
...kopfzeilen(kopf).map(
|
||||
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
|
||||
),
|
||||
new Paragraph({
|
||||
children: [zeile('')],
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
|
||||
spacing: { after: 200 },
|
||||
}),
|
||||
]
|
||||
|
||||
for (const abschnitt of abschnitte) {
|
||||
if (abschnitt.titel) {
|
||||
inhalt.push(
|
||||
new Paragraph({
|
||||
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 260, after: 100 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const absatz of abschnitt.absaetze) {
|
||||
const istAufzaehlung = absatz.startsWith('•')
|
||||
inhalt.push(
|
||||
new Paragraph({
|
||||
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
|
||||
bullet: istAufzaehlung ? { level: 0 } : undefined,
|
||||
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
|
||||
spacing: { after: 120, line: 300 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
inhalt.push(...unterschriftenblock(kopf))
|
||||
|
||||
return new Document({
|
||||
creator: kopf.erstelltVon,
|
||||
title: kopf.titel,
|
||||
description: `${kopf.titel} für ${kopf.betreute.name}`,
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
margin: {
|
||||
top: convertMillimetersToTwip(25),
|
||||
bottom: convertMillimetersToTwip(20),
|
||||
left: convertMillimetersToTwip(25),
|
||||
right: convertMillimetersToTwip(20),
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.RIGHT,
|
||||
children: [
|
||||
zeile(
|
||||
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
|
||||
{ groesse: 8, grau: true },
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
footers: {
|
||||
default: new Footer({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
zeile('Seite ', { groesse: 8, grau: true }),
|
||||
new TextRun({
|
||||
children: [PageNumber.CURRENT],
|
||||
size: 16,
|
||||
color: '555555',
|
||||
font: SCHRIFT,
|
||||
}),
|
||||
zeile(' von ', { groesse: 8, grau: true }),
|
||||
new TextRun({
|
||||
children: [PageNumber.TOTAL_PAGES],
|
||||
size: 16,
|
||||
color: '555555',
|
||||
font: SCHRIFT,
|
||||
}),
|
||||
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
|
||||
groesse: 8,
|
||||
grau: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
children: inhalt,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/** Fertige .docx-Datei als Buffer. */
|
||||
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
|
||||
return Packer.toBuffer(docxAusEntwurf(kopf, text))
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import 'server-only'
|
||||
import type { Betreute, User } from '@prisma/client'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { oeffnen } from '@/lib/crypto/feld'
|
||||
import type { Berichtskopf } from '@/lib/pdf/rahmen'
|
||||
import { alsTag, heute, monatePlus, tagePlus } from '@/lib/finanzen/datum'
|
||||
import { ibanMaskieren } from '@/lib/finanzen/iban'
|
||||
import { betreuungsjahr, type Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
|
||||
import { laufendeBewegungen, type BuchungEingabe, type LaufendeBewegungen } from '@/lib/finanzen/kassenbuch'
|
||||
import {
|
||||
rechnungslegungBerechnen,
|
||||
saldoZumStichtag,
|
||||
type KontoEingabe,
|
||||
type Rechnungsdaten,
|
||||
type Variante,
|
||||
} from '@/lib/finanzen/rechnungslegung'
|
||||
import { vermoegenBerechnen, type Vermoegensbild } from '@/lib/finanzen/vermoegen'
|
||||
import { KONTO_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
|
||||
|
||||
/**
|
||||
* Gemeinsamer Unterbau der beiden Finanzberichte.
|
||||
*
|
||||
* Hier - und nur hier - wird aus der Datenbank gelesen. Die Rechnungen
|
||||
* selbst stehen in src/lib/finanzen/ und kommen ohne Datenbank aus; die
|
||||
* Darstellung steht in den beiden .tsx-Dateien daneben. Diese Trennung
|
||||
* ist der Grund, warum sich jede Zahl im Bericht als Testfall
|
||||
* nachrechnen laesst.
|
||||
*/
|
||||
|
||||
export type BerichtsBetreute = {
|
||||
id: string
|
||||
name: string
|
||||
geburtsdatum: Date | null
|
||||
betreuungsgericht: string | null
|
||||
aktenzeichenGericht: string | null
|
||||
bestellungAm: Date | null
|
||||
befreiterBetreuer: boolean
|
||||
}
|
||||
|
||||
export function betreuteFuerBericht(betreute: Betreute): BerichtsBetreute {
|
||||
return {
|
||||
id: betreute.id,
|
||||
name: `${betreute.vorname} ${betreute.nachname}`,
|
||||
geburtsdatum: betreute.geburtsdatum,
|
||||
betreuungsgericht: betreute.betreuungsgericht,
|
||||
aktenzeichenGericht: betreute.aktenzeichenGericht,
|
||||
bestellungAm: betreute.bestellungAm,
|
||||
befreiterBetreuer: betreute.befreiterBetreuer,
|
||||
}
|
||||
}
|
||||
|
||||
/** Kopfzeile nach dem Vertrag aus docs/SCHNITTSTELLEN.md, Abschnitt 2. */
|
||||
export function berichtskopf(eingabe: {
|
||||
titel: string
|
||||
betreute: BerichtsBetreute
|
||||
rechtsgrundlage: string
|
||||
erstelltVon: string
|
||||
stichtag?: Date | null
|
||||
zeitraumVon?: Date | null
|
||||
zeitraumBis?: Date | null
|
||||
erstelltAm?: Date
|
||||
}): Berichtskopf {
|
||||
const behoerde = eingabe.betreute.betreuungsgericht
|
||||
? eingabe.betreute.aktenzeichenGericht
|
||||
? `${eingabe.betreute.betreuungsgericht} · Az. ${eingabe.betreute.aktenzeichenGericht}`
|
||||
: eingabe.betreute.betreuungsgericht
|
||||
: undefined
|
||||
|
||||
return {
|
||||
titel: eingabe.titel,
|
||||
betreute: { name: eingabe.betreute.name, geburtsdatum: eingabe.betreute.geburtsdatum },
|
||||
behoerde,
|
||||
aktenzeichen: eingabe.betreute.aktenzeichenGericht ?? undefined,
|
||||
zeitraumVon: eingabe.zeitraumVon ?? null,
|
||||
zeitraumBis: eingabe.zeitraumBis ?? null,
|
||||
stichtag: eingabe.stichtag ?? null,
|
||||
rechtsgrundlage: eingabe.rechtsgrundlage,
|
||||
erstelltAm: eingabe.erstelltAm ?? new Date(),
|
||||
erstelltVon: eingabe.erstelltVon,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Laden
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Alle Buchungen einer betreuten Person, entschluesselt und um die
|
||||
* Angaben ergaenzt, die die Auswertung braucht (Kontoname, Belegtitel,
|
||||
* Betreuerkonto ja/nein).
|
||||
*/
|
||||
export async function buchungenLaden(
|
||||
betreuteId: string,
|
||||
zeitraum?: { von: Date; bis: Date },
|
||||
): Promise<BuchungEingabe[]> {
|
||||
const zeilen = await db.buchung.findMany({
|
||||
where: {
|
||||
betreuteId,
|
||||
...nichtGeloescht,
|
||||
...(zeitraum
|
||||
? { datum: { gte: alsTag(zeitraum.von), lte: alsTag(zeitraum.bis) } }
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
konto: { select: { id: true, bezeichnung: true, istBetreuerkonto: true } },
|
||||
beleg: { select: { id: true, titel: true } },
|
||||
},
|
||||
orderBy: [{ datum: 'asc' }, { erstelltAm: 'asc' }],
|
||||
})
|
||||
|
||||
return 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Konten mit ihrer vollstaendigen Saldostand-Historie. */
|
||||
export async function kontenLaden(betreuteId: string): Promise<KontoEingabe[]> {
|
||||
const zeilen = await db.konto.findMany({
|
||||
where: { betreuteId, ...nichtGeloescht },
|
||||
include: { saldostaende: { orderBy: { stichtag: 'asc' } } },
|
||||
orderBy: [{ istBetreuerkonto: 'asc' }, { bezeichnung: 'asc' }],
|
||||
})
|
||||
|
||||
return zeilen.map((konto) => {
|
||||
const offen = oeffnen('Konto', konto)
|
||||
return {
|
||||
id: konto.id,
|
||||
bezeichnung: konto.bezeichnung,
|
||||
bank: konto.bank,
|
||||
art: konto.art,
|
||||
istBetreuerkonto: konto.istBetreuerkonto,
|
||||
ibanMaskiert: offen.iban ? ibanMaskieren(offen.iban) : null,
|
||||
saldostaende: konto.saldostaende.map((s) => ({
|
||||
stichtag: s.stichtag,
|
||||
betragCent: s.betragCent,
|
||||
quelle: s.quelle,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type KontostandZeile = {
|
||||
kontoId: string
|
||||
bezeichnung: string
|
||||
bank: string | null
|
||||
artBeschriftung: string
|
||||
istBetreuerkonto: boolean
|
||||
ibanMaskiert: string | null
|
||||
betragCent: bigint | null
|
||||
stichtag: Date | null
|
||||
quelle: string | null
|
||||
}
|
||||
|
||||
/** Kontostaende zu einem Stichtag - fuer Uebersicht und Verzeichnis. */
|
||||
export function kontostaendeZumStichtag(
|
||||
konten: KontoEingabe[],
|
||||
stichtag: Date,
|
||||
): KontostandZeile[] {
|
||||
return konten.map((konto) => {
|
||||
const saldo = saldoZumStichtag(konto.saldostaende, stichtag)
|
||||
return {
|
||||
kontoId: konto.id,
|
||||
bezeichnung: konto.bezeichnung,
|
||||
bank: konto.bank,
|
||||
artBeschriftung: KONTO_ART_BESCHRIFTUNG[konto.art],
|
||||
istBetreuerkonto: konto.istBetreuerkonto,
|
||||
ibanMaskiert: konto.ibanMaskiert ?? null,
|
||||
betragCent: saldo?.betragCent ?? null,
|
||||
stichtag: saldo ? alsTag(saldo.stichtag) : null,
|
||||
quelle: saldo?.quelle ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Vermoegensposten mit ihrer Wert-Historie, gerechnet auf einen Stichtag. */
|
||||
export async function vermoegenLaden(
|
||||
betreuteId: string,
|
||||
stichtag: Date,
|
||||
): Promise<Vermoegensbild> {
|
||||
const posten = await db.vermoegensposten.findMany({
|
||||
where: { betreuteId, ...nichtGeloescht },
|
||||
include: {
|
||||
wertstaende: {
|
||||
orderBy: { stichtag: 'asc' },
|
||||
include: { nachweisDokument: { select: { id: true, titel: true } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return vermoegenBerechnen(
|
||||
posten.map((p) => ({
|
||||
id: p.id,
|
||||
bezeichnung: p.bezeichnung,
|
||||
art: p.art,
|
||||
kontoId: p.kontoId,
|
||||
immobilieId: p.immobilieId,
|
||||
vertragId: p.vertragId,
|
||||
bewertungSozialhilfe: p.bewertungSozialhilfe,
|
||||
wertstaende: p.wertstaende.map((w) => ({
|
||||
stichtag: w.stichtag,
|
||||
wertCent: w.wertCent,
|
||||
wertquelle: w.wertquelle,
|
||||
nachweisDokumentId: w.nachweisDokumentId,
|
||||
nachweisTitel: w.nachweisDokument?.titel ?? null,
|
||||
bemerkung: w.bemerkung,
|
||||
})),
|
||||
})),
|
||||
stichtag,
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vermoegensverzeichnis (§ 1835 BGB)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type VermoegensverzeichnisDaten = {
|
||||
kopf: Berichtskopf
|
||||
betreute: BerichtsBetreute
|
||||
stichtag: Date
|
||||
/** Konten mit ihrem Stand am Stichtag - ohne Konten des Betreuers. */
|
||||
kontostaende: KontostandZeile[]
|
||||
summeKontenCent: bigint
|
||||
/** Uebrige Vermoegensposten. Posten der Art KONTO bleiben aussen vor. */
|
||||
vermoegen: Vermoegensbild
|
||||
summeAktivaCent: bigint
|
||||
summePassivaCent: bigint
|
||||
reinvermoegenCent: bigint
|
||||
laufend: LaufendeBewegungen
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Konten erscheinen im Verzeichnis mit ihrem Saldostand, nicht als
|
||||
* Vermoegensposten. Deshalb werden Posten der Art KONTO uebersprungen -
|
||||
* sonst stuende dasselbe Guthaben zweimal in der Summe.
|
||||
*/
|
||||
export async function vermoegensverzeichnisDaten(eingabe: {
|
||||
betreute: Betreute
|
||||
stichtag: Date
|
||||
erstelltVon: string
|
||||
monateRueckblick?: number
|
||||
}): Promise<VermoegensverzeichnisDaten> {
|
||||
const stichtag = alsTag(eingabe.stichtag)
|
||||
const monate = eingabe.monateRueckblick ?? 12
|
||||
const betreute = betreuteFuerBericht(eingabe.betreute)
|
||||
|
||||
const [konten, alleBuchungen, vermoegenAlle] = await Promise.all([
|
||||
kontenLaden(betreute.id),
|
||||
buchungenLaden(betreute.id),
|
||||
vermoegenLaden(betreute.id, stichtag),
|
||||
])
|
||||
|
||||
const kontostaende = kontostaendeZumStichtag(
|
||||
konten.filter((k) => !k.istBetreuerkonto),
|
||||
stichtag,
|
||||
)
|
||||
const summeKontenCent = kontostaende.reduce((s, z) => s + (z.betragCent ?? 0n), 0n)
|
||||
|
||||
// Posten der Art KONTO werden herausgenommen - siehe Kommentar oben.
|
||||
const vermoegen: Vermoegensbild = {
|
||||
...vermoegenAlle,
|
||||
aktivaGruppen: vermoegenAlle.aktivaGruppen.filter((g) => g.art !== 'KONTO'),
|
||||
aktiva: vermoegenAlle.aktiva.filter((p) => p.art !== 'KONTO'),
|
||||
ohneWert: vermoegenAlle.ohneWert.filter((p) => p.art !== 'KONTO'),
|
||||
summeAktivaCent: vermoegenAlle.aktiva
|
||||
.filter((p) => p.art !== 'KONTO')
|
||||
.reduce((s, p) => s + p.wertCent, 0n),
|
||||
}
|
||||
|
||||
const von = tagePlus(monatePlus(stichtag, -monate), 1)
|
||||
const laufend = laufendeBewegungen(alleBuchungen, von, stichtag, monate)
|
||||
|
||||
const summeAktivaCent = summeKontenCent + vermoegen.summeAktivaCent
|
||||
const summePassivaCent = vermoegen.summePassivaCent
|
||||
|
||||
const hinweise: string[] = []
|
||||
for (const zeile of kontostaende) {
|
||||
if (zeile.betragCent === null) {
|
||||
hinweise.push(
|
||||
`Für „${zeile.bezeichnung}“ ist zum Stichtag kein Saldostand erfasst. Das Konto steht ` +
|
||||
'ohne Betrag im Verzeichnis.',
|
||||
)
|
||||
} else if (zeile.stichtag && zeile.stichtag.getTime() !== stichtag.getTime()) {
|
||||
hinweise.push(
|
||||
`Der Stand von „${zeile.bezeichnung}“ stammt vom ${kurz(zeile.stichtag)}, nicht vom Stichtag.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const posten of vermoegen.ohneWert) {
|
||||
hinweise.push(
|
||||
`Für „${posten.bezeichnung}“ liegt zum Stichtag keine Bewertung vor. ` +
|
||||
(posten.spaetereWertstaende > 0
|
||||
? 'Es gibt nur Wertstände nach dem Stichtag – die dürfen hier nicht verwendet werden.'
|
||||
: 'Es ist noch kein Wertstand erfasst.'),
|
||||
)
|
||||
}
|
||||
if (vermoegen.aeltesterWertTage !== null && vermoegen.aeltesterWertTage > 365) {
|
||||
hinweise.push(
|
||||
`Der älteste verwendete Wert ist ${vermoegen.aeltesterWertTage} Tage alt. ` +
|
||||
'Vor der Abgabe lohnt sich eine aktuelle Bewertung.',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
kopf: berichtskopf({
|
||||
titel: 'Vermögensverzeichnis',
|
||||
betreute,
|
||||
rechtsgrundlage: '§ 1835 BGB',
|
||||
erstelltVon: eingabe.erstelltVon,
|
||||
stichtag,
|
||||
}),
|
||||
betreute,
|
||||
stichtag,
|
||||
kontostaende,
|
||||
summeKontenCent,
|
||||
vermoegen,
|
||||
summeAktivaCent,
|
||||
summePassivaCent,
|
||||
reinvermoegenCent: summeAktivaCent - summePassivaCent,
|
||||
laufend,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rechnungslegung (§§ 1859, 1865 BGB)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type RechnungslegungDaten = {
|
||||
kopf: Berichtskopf
|
||||
betreute: BerichtsBetreute
|
||||
jahr: Betreuungsjahr
|
||||
rechnung: Rechnungsdaten
|
||||
/** Vermoegen am Ende des Zeitraums - gehoert in beide Varianten. */
|
||||
vermoegenEnde: Vermoegensbild
|
||||
}
|
||||
|
||||
export async function rechnungslegungDaten(eingabe: {
|
||||
betreute: Betreute
|
||||
jahrNummer: number
|
||||
variante: Variante
|
||||
erstelltVon: string
|
||||
}): Promise<RechnungslegungDaten> {
|
||||
if (!eingabe.betreute.bestellungAm) {
|
||||
throw new Error(
|
||||
'Ohne Bestellungsdatum lässt sich kein Betreuungsjahr bestimmen. ' +
|
||||
'Bitte zuerst in den Stammdaten das Datum der Bestellung eintragen.',
|
||||
)
|
||||
}
|
||||
|
||||
const betreute = betreuteFuerBericht(eingabe.betreute)
|
||||
const jahr = betreuungsjahr(eingabe.betreute.bestellungAm, eingabe.jahrNummer)
|
||||
|
||||
const [konten, buchungen, vermoegenEnde] = await Promise.all([
|
||||
kontenLaden(betreute.id),
|
||||
buchungenLaden(betreute.id, { von: jahr.von, bis: jahr.bis }),
|
||||
vermoegenLaden(betreute.id, jahr.bis),
|
||||
])
|
||||
|
||||
const rechnung = rechnungslegungBerechnen({
|
||||
von: jahr.von,
|
||||
bis: jahr.bis,
|
||||
variante: eingabe.variante,
|
||||
konten,
|
||||
buchungen,
|
||||
betreuungsjahr: jahr,
|
||||
heute: heute(),
|
||||
})
|
||||
|
||||
const titel =
|
||||
eingabe.variante === 'UEBERSICHT_1859'
|
||||
? 'Vermögensübersicht'
|
||||
: 'Rechnungslegung'
|
||||
|
||||
return {
|
||||
kopf: berichtskopf({
|
||||
titel: `${titel} – ${jahr.nummer}. Betreuungsjahr`,
|
||||
betreute,
|
||||
rechtsgrundlage: eingabe.variante === 'UEBERSICHT_1859' ? '§ 1859 BGB' : '§ 1865 BGB',
|
||||
erstelltVon: eingabe.erstelltVon,
|
||||
zeitraumVon: jahr.von,
|
||||
zeitraumBis: jahr.bis,
|
||||
}),
|
||||
betreute,
|
||||
jahr,
|
||||
rechnung,
|
||||
vermoegenEnde,
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Name, der unter dem Bericht steht. */
|
||||
export function erstellerName(benutzer: User): string {
|
||||
return benutzer.name
|
||||
}
|
||||
|
||||
function kurz(datum: Date): string {
|
||||
const tag = String(datum.getUTCDate()).padStart(2, '0')
|
||||
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${tag}.${monat}.${datum.getUTCFullYear()}`
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
import type {
|
||||
Aufgabenkreis,
|
||||
GesundheitsArt,
|
||||
KontaktArt,
|
||||
VorgangKategorie,
|
||||
VorgangStatus,
|
||||
} from '@prisma/client'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Jahresbericht nach § 1863 BGB - Textentwurf.
|
||||
*
|
||||
* Diese Datei erzeugt einen ENTWURF, keinen fertigen Bericht. Sie fasst
|
||||
* zusammen, was ueber das Jahr in Kontaktprotokollen, Gesundheitsereignissen
|
||||
* und Vorgaengen festgehalten wurde, und giesst es in ganze Saetze. Was
|
||||
* daraus wird, entscheidet der Betreuer: Der Text ist im Browser
|
||||
* bearbeitbar, und erst der ueberarbeitete Text wird zu PDF oder DOCX.
|
||||
*
|
||||
* Warum ueberhaupt ein Entwurf? Weil der Bericht sonst am Jahresende aus
|
||||
* dem Gedaechtnis geschrieben wird - und dann fehlt die Haelfte der
|
||||
* Besuche, und die Wuensche der betreuten Person, die im Maerz geaeussert
|
||||
* wurden, tauchen gar nicht auf.
|
||||
*
|
||||
* Bewusst ohne Datenbank und ohne React: reine Funktionen auf reinen
|
||||
* Daten, damit die Textbausteine testbar bleiben.
|
||||
*/
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Eingangsdaten
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type JahresberichtBetreute = {
|
||||
anrede: string | null
|
||||
vorname: string
|
||||
nachname: string
|
||||
geburtsdatum: Date | null
|
||||
heimName: string | null
|
||||
heimStrasse: string | null
|
||||
heimPlz: string | null
|
||||
heimOrt: string | null
|
||||
heimZimmer: string | null
|
||||
heimEinzugAm: Date | null
|
||||
fruehereStrasse: string | null
|
||||
frueherePlz: string | null
|
||||
fruehererOrt: string | null
|
||||
pflegegrad: number | null
|
||||
pflegegradSeit: Date | null
|
||||
schwerbehinderungGdB: number | null
|
||||
schwerbehinderungMerkzeichen: string | null
|
||||
/** Klartext aus diagnosenVerschluesselt. */
|
||||
diagnosen: string | null
|
||||
/** Klartext aus ermittelterWilleVerschluesselt. */
|
||||
ermittelterWille: string | null
|
||||
patientenverfuegungVorhanden: boolean
|
||||
vorsorgevollmachtVorhanden: boolean
|
||||
aufgabenkreise: Aufgabenkreis[]
|
||||
aufgabenkreiseFreitext: string | null
|
||||
betreuungsgericht: string | null
|
||||
aktenzeichenGericht: string | null
|
||||
bestellungAm: Date | null
|
||||
bestellungBis: Date | null
|
||||
}
|
||||
|
||||
export type JahresberichtKontakt = {
|
||||
datum: Date
|
||||
art: KontaktArt
|
||||
mitBetreuter: boolean
|
||||
dauerMinuten: number | null
|
||||
gespraechspartner: string | null
|
||||
/** Klartext aus wuenscheVerschluesselt. */
|
||||
wuensche: string | null
|
||||
}
|
||||
|
||||
export type JahresberichtGesundheit = {
|
||||
datum: Date
|
||||
bisDatum: Date | null
|
||||
art: GesundheitsArt
|
||||
titel: string
|
||||
einrichtung: string | null
|
||||
abwesenheitstage: number | null
|
||||
}
|
||||
|
||||
export type JahresberichtVorgang = {
|
||||
titel: string
|
||||
kategorie: VorgangKategorie
|
||||
status: VorgangStatus
|
||||
naechsterSchritt: string | null
|
||||
gegenstelle: string | null
|
||||
genehmigungspflichtig: boolean
|
||||
erledigtAm: Date | null
|
||||
}
|
||||
|
||||
export type JahresberichtQuellen = {
|
||||
betreute: JahresberichtBetreute
|
||||
zeitraumVon: Date
|
||||
zeitraumBis: Date
|
||||
kontakte: JahresberichtKontakt[]
|
||||
gesundheit: JahresberichtGesundheit[]
|
||||
vorgaenge: JahresberichtVorgang[]
|
||||
}
|
||||
|
||||
export type Berichtsabschnitt = {
|
||||
nummer: number
|
||||
titel: string
|
||||
rechtsgrundlage: string
|
||||
absaetze: string[]
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Beschriftungen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Stelle im Entwurf, an der der Betreuer selbst weiterschreiben muss. */
|
||||
export const ERGAENZEN = '[bitte ergänzen]'
|
||||
|
||||
export const ENTWURFSHINWEIS =
|
||||
'Entwurf. Dieser Text ist aus den erfassten Kontaktprotokollen, ' +
|
||||
'Gesundheitsereignissen und Vorgängen zusammengestellt worden. Er ist ein ' +
|
||||
'Vorschlag, keine fertige Berichterstattung: Bitte lies ihn durch, streiche, ' +
|
||||
'was nicht stimmt, und ergänze, was nur du weißt. Erst danach abgeben.'
|
||||
|
||||
export const KONTAKTART_EINZAHL: Record<KontaktArt, string> = {
|
||||
BESUCH: 'Besuch',
|
||||
TELEFONAT: 'Telefonat',
|
||||
EMAIL: 'E-Mail',
|
||||
BRIEF: 'Brief',
|
||||
VIDEOCALL: 'Videotelefonat',
|
||||
FAX: 'Fax',
|
||||
VORSPRACHE: 'persönliche Vorsprache',
|
||||
}
|
||||
|
||||
export const KONTAKTART_MEHRZAHL: Record<KontaktArt, string> = {
|
||||
BESUCH: 'Besuche',
|
||||
TELEFONAT: 'Telefonate',
|
||||
EMAIL: 'E-Mails',
|
||||
BRIEF: 'Briefe',
|
||||
VIDEOCALL: 'Videotelefonate',
|
||||
FAX: 'Faxe',
|
||||
VORSPRACHE: 'persönliche Vorsprachen',
|
||||
}
|
||||
|
||||
export const GESUNDHEITSART_MEHRZAHL: Record<GesundheitsArt, string> = {
|
||||
ARZTBESUCH: 'Arztbesuche',
|
||||
KRANKENHAUS: 'Krankenhausaufenthalte',
|
||||
EXAZERBATION: 'akute Verschlechterungen der Atemwegserkrankung',
|
||||
STURZ: 'Stürze',
|
||||
MEDIKATIONSAENDERUNG: 'Änderungen der Medikation',
|
||||
SAUERSTOFFVERSORGUNG: 'Maßnahmen der Sauerstoffversorgung',
|
||||
INHALATION: 'Inhalationsbehandlungen',
|
||||
HILFSMITTEL: 'Hilfsmittelversorgungen',
|
||||
IMPFUNG: 'Impfungen',
|
||||
REHA: 'Rehabilitationsmaßnahmen',
|
||||
PFLEGEBEGUTACHTUNG: 'Pflegebegutachtungen',
|
||||
HEILMITTEL: 'Heilmittelbehandlungen',
|
||||
SONSTIGES: 'sonstige gesundheitliche Ereignisse',
|
||||
}
|
||||
|
||||
export const KATEGORIE_BESCHRIFTUNG: Record<VorgangKategorie, string> = {
|
||||
GERICHT: 'Betreuungsgericht',
|
||||
SOZIALHILFE: 'Sozialhilfe und Hilfe zur Pflege',
|
||||
IMMOBILIE: 'Immobilie und Wohnraum',
|
||||
VERSICHERUNG: 'Versicherungen',
|
||||
HEIM_PFLEGE: 'Heim und Pflege',
|
||||
VERTRAEGE: 'Verträge',
|
||||
STEUERN: 'Steuern',
|
||||
SONSTIGES: 'Sonstiges',
|
||||
}
|
||||
|
||||
export const AUFGABENKREIS_BESCHRIFTUNG: Record<Aufgabenkreis, string> = {
|
||||
VERMOEGENSSORGE: 'Vermögenssorge',
|
||||
GESUNDHEITSSORGE: 'Gesundheitssorge',
|
||||
AUFENTHALTSBESTIMMUNG: 'Aufenthaltsbestimmung',
|
||||
WOHNUNGSANGELEGENHEITEN: 'Wohnungsangelegenheiten',
|
||||
BEHOERDENANGELEGENHEITEN: 'Behördenangelegenheiten',
|
||||
POST_UND_FERNMELDEVERKEHR: 'Post- und Fernmeldeverkehr',
|
||||
RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG: 'Vertretung gegenüber Behörden und Gerichten',
|
||||
ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN:
|
||||
'Entscheidung über freiheitsentziehende Maßnahmen',
|
||||
SONSTIGES: 'weitere Aufgaben',
|
||||
}
|
||||
|
||||
export const GLIEDERUNG: { nummer: number; titel: string; rechtsgrundlage: string }[] = [
|
||||
{ nummer: 1, titel: 'Persönliche Verhältnisse', rechtsgrundlage: '§ 1863 Abs. 3 Nr. 1 BGB' },
|
||||
{
|
||||
nummer: 2,
|
||||
titel: 'Persönlicher Kontakt zur betreuten Person',
|
||||
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 2, § 1863 Abs. 2 BGB',
|
||||
},
|
||||
{
|
||||
nummer: 3,
|
||||
titel: 'Wünsche der betreuten Person und ihre Umsetzung',
|
||||
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 3, § 1821 BGB',
|
||||
},
|
||||
{
|
||||
nummer: 4,
|
||||
titel: 'Erledigte und laufende Angelegenheiten',
|
||||
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
|
||||
},
|
||||
{
|
||||
nummer: 5,
|
||||
titel: 'Einschätzung zur Fortführung der Betreuung',
|
||||
rechtsgrundlage: '§ 1863 Abs. 3 Nr. 4 BGB',
|
||||
},
|
||||
]
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Sprachliche Helfer
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** "a", "a und b", "a, b und c" - deutsche Aufzaehlung ohne Komma vor "und". */
|
||||
export function aufzaehlung(teile: readonly string[]): string {
|
||||
const sauber = teile.filter((t) => t.trim() !== '')
|
||||
if (sauber.length === 0) return ''
|
||||
if (sauber.length === 1) return sauber[0]!
|
||||
return `${sauber.slice(0, -1).join(', ')} und ${sauber[sauber.length - 1]!}`
|
||||
}
|
||||
|
||||
/** "einmal", "zweimal", "17-mal" - so, wie man es schreiben wuerde. */
|
||||
export function malAngabe(anzahl: number): string {
|
||||
const woerter = ['keinmal', 'einmal', 'zweimal', 'dreimal', 'viermal', 'fünfmal', 'sechsmal']
|
||||
return woerter[anzahl] ?? `${anzahl}-mal`
|
||||
}
|
||||
|
||||
/** 95 Minuten -> "1 Stunde und 35 Minuten". */
|
||||
export function dauerAusgeschrieben(minuten: number): string {
|
||||
const stunden = Math.floor(minuten / 60)
|
||||
const rest = minuten % 60
|
||||
const teile: string[] = []
|
||||
if (stunden > 0) teile.push(stunden === 1 ? '1 Stunde' : `${stunden} Stunden`)
|
||||
if (rest > 0 || stunden === 0) teile.push(rest === 1 ? '1 Minute' : `${rest} Minuten`)
|
||||
return aufzaehlung(teile)
|
||||
}
|
||||
|
||||
/** "Frau Maria Musterfrau" - mit Anrede, wenn sie gepflegt ist. */
|
||||
export function vollerName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
|
||||
return [betreute.anrede, betreute.vorname, betreute.nachname].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
/** "Frau Musterfrau" - die Form, die im Bericht laufend gebraucht wird. */
|
||||
export function kurzName(betreute: Pick<JahresberichtBetreute, 'anrede' | 'vorname' | 'nachname'>): string {
|
||||
return betreute.anrede ? `${betreute.anrede} ${betreute.nachname}` : `${betreute.vorname} ${betreute.nachname}`
|
||||
}
|
||||
|
||||
/** "sie" oder "er" - richtet sich nach der gepflegten Anrede. */
|
||||
function pronomen(betreute: Pick<JahresberichtBetreute, 'anrede'>): { nominativ: string; possessiv: string } {
|
||||
const maennlich = (betreute.anrede ?? '').trim().toLowerCase().startsWith('herr')
|
||||
return maennlich ? { nominativ: 'er', possessiv: 'seine' } : { nominativ: 'sie', possessiv: 'ihre' }
|
||||
}
|
||||
|
||||
function anschrift(strasse: string | null, plz: string | null, ort: string | null): string | null {
|
||||
const zeile = [strasse, [plz, ort].filter(Boolean).join(' ').trim()].filter(Boolean).join(', ')
|
||||
return zeile.trim() === '' ? null : zeile
|
||||
}
|
||||
|
||||
function tageZwischen(von: Date, bis: Date): number {
|
||||
return Math.max(0, Math.round((bis.getTime() - von.getTime()) / 86_400_000))
|
||||
}
|
||||
|
||||
function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
|
||||
return datum.getTime() >= von.getTime() && datum.getTime() <= bis.getTime()
|
||||
}
|
||||
|
||||
function haeufigkeit<T extends string>(werte: T[]): Map<T, number> {
|
||||
const karte = new Map<T, number>()
|
||||
for (const wert of werte) karte.set(wert, (karte.get(wert) ?? 0) + 1)
|
||||
return karte
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Die fuenf Abschnitte
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function abschnittPersoenlicheVerhaeltnisse(quellen: JahresberichtQuellen): string[] {
|
||||
const { betreute, zeitraumVon, zeitraumBis } = quellen
|
||||
const name = kurzName(betreute)
|
||||
const pron = pronomen(betreute)
|
||||
const absaetze: string[] = []
|
||||
|
||||
// --- Aufenthalt ---
|
||||
const heimAnschrift = anschrift(betreute.heimStrasse, betreute.heimPlz, betreute.heimOrt)
|
||||
if (betreute.heimName) {
|
||||
const seit = betreute.heimEinzugAm ? ` seit dem ${formatDatum(betreute.heimEinzugAm)}` : ''
|
||||
const wo = heimAnschrift ? ` (${heimAnschrift})` : ''
|
||||
const zimmer = betreute.heimZimmer ? `, Zimmer ${betreute.heimZimmer}` : ''
|
||||
absaetze.push(
|
||||
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}, lebt${seit} ` +
|
||||
`in der Einrichtung ${betreute.heimName}${wo}${zimmer}. An diesem Aufenthaltsort hat sich ` +
|
||||
`im Berichtszeitraum nichts geändert. ${ERGAENZEN}, falls doch.`,
|
||||
)
|
||||
} else {
|
||||
absaetze.push(
|
||||
`${vollerName(betreute)}, geboren am ${formatDatum(betreute.geburtsdatum)}. Zum Aufenthaltsort ` +
|
||||
`liegen in dieser Anwendung keine Angaben vor: ${ERGAENZEN}, wo ${pron.nominativ} im ` +
|
||||
`Berichtszeitraum gelebt hat.`,
|
||||
)
|
||||
}
|
||||
|
||||
const frueher = anschrift(betreute.fruehereStrasse, betreute.frueherePlz, betreute.fruehererOrt)
|
||||
if (frueher) {
|
||||
absaetze.push(
|
||||
`Die letzte eigene Wohnanschrift lautet ${frueher}. ${ERGAENZEN}: ob die Wohnung im ` +
|
||||
`Berichtszeitraum weiter bestanden hat, aufgelöst wurde oder eine Auflösung ansteht.`,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Pflege und Behinderung ---
|
||||
const pflegesaetze: string[] = []
|
||||
if (betreute.pflegegrad !== null) {
|
||||
const seit = betreute.pflegegradSeit ? ` seit dem ${formatDatum(betreute.pflegegradSeit)}` : ''
|
||||
pflegesaetze.push(`${name} ist${seit} in Pflegegrad ${betreute.pflegegrad} eingestuft.`)
|
||||
} else {
|
||||
pflegesaetze.push(`Ein Pflegegrad ist in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`)
|
||||
}
|
||||
if (betreute.schwerbehinderungGdB !== null) {
|
||||
const merk = betreute.schwerbehinderungMerkzeichen
|
||||
? ` mit dem Merkzeichen ${betreute.schwerbehinderungMerkzeichen}`
|
||||
: ''
|
||||
pflegesaetze.push(
|
||||
`Es ist ein Grad der Behinderung von ${betreute.schwerbehinderungGdB}${merk} festgestellt.`,
|
||||
)
|
||||
}
|
||||
absaetze.push(pflegesaetze.join(' '))
|
||||
|
||||
// --- Gesundheitszustand im Berichtszeitraum ---
|
||||
const ereignisse = quellen.gesundheit.filter((e) => imZeitraum(e.datum, zeitraumVon, zeitraumBis))
|
||||
const gesundheitssaetze: string[] = []
|
||||
|
||||
if (betreute.diagnosen && betreute.diagnosen.trim() !== '') {
|
||||
gesundheitssaetze.push(
|
||||
`Der Gesundheitszustand ist geprägt von: ${betreute.diagnosen.trim().replace(/\s+/g, ' ')}.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (ereignisse.length === 0) {
|
||||
gesundheitssaetze.push(
|
||||
`Für den Zeitraum vom ${formatDatum(zeitraumVon)} bis zum ${formatDatum(zeitraumBis)} sind ` +
|
||||
`keine gesundheitlichen Ereignisse festgehalten. ${ERGAENZEN}, sofern es welche gab.`,
|
||||
)
|
||||
} else {
|
||||
const nachArt = haeufigkeit(ereignisse.map((e) => e.art))
|
||||
const aufzaehlungTeile = [...nachArt.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([art, anzahl]) => `${anzahl} ${GESUNDHEITSART_MEHRZAHL[art]}`)
|
||||
gesundheitssaetze.push(
|
||||
`Im Berichtszeitraum sind ${ereignisse.length} gesundheitliche Ereignisse festgehalten: ` +
|
||||
`${aufzaehlung(aufzaehlungTeile)}.`,
|
||||
)
|
||||
|
||||
const klinik = ereignisse.filter((e) => e.art === 'KRANKENHAUS' || e.art === 'REHA')
|
||||
if (klinik.length > 0) {
|
||||
const beschreibungen = klinik.map((e) => {
|
||||
const bis = e.bisDatum ? ` bis ${formatDatum(e.bisDatum)}` : ''
|
||||
const wo = e.einrichtung ? ` (${e.einrichtung})` : ''
|
||||
return `${formatDatum(e.datum)}${bis}: ${e.titel}${wo}`
|
||||
})
|
||||
gesundheitssaetze.push(
|
||||
`Stationäre Aufenthalte im Einzelnen — ${aufzaehlung(beschreibungen)}.`,
|
||||
)
|
||||
}
|
||||
|
||||
const abwesend = ereignisse.reduce((summe, e) => summe + (e.abwesenheitstage ?? 0), 0)
|
||||
if (abwesend > 0) {
|
||||
gesundheitssaetze.push(
|
||||
`Insgesamt ergaben sich daraus ${abwesend} Abwesenheitstage aus der Einrichtung.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
absaetze.push(gesundheitssaetze.join(' '))
|
||||
|
||||
// --- Vorsorge ---
|
||||
const vorsorge: string[] = []
|
||||
vorsorge.push(
|
||||
betreute.patientenverfuegungVorhanden
|
||||
? 'Eine Patientenverfügung liegt vor und ist mir bekannt.'
|
||||
: 'Eine Patientenverfügung liegt nach meinem Kenntnisstand nicht vor.',
|
||||
)
|
||||
vorsorge.push(
|
||||
betreute.vorsorgevollmachtVorhanden
|
||||
? 'Eine Vorsorgevollmacht ist vorhanden.'
|
||||
: 'Eine Vorsorgevollmacht ist nicht vorhanden.',
|
||||
)
|
||||
absaetze.push(vorsorge.join(' '))
|
||||
|
||||
return absaetze
|
||||
}
|
||||
|
||||
function abschnittKontakt(quellen: JahresberichtQuellen): string[] {
|
||||
const { betreute, zeitraumVon, zeitraumBis } = quellen
|
||||
const name = kurzName(betreute)
|
||||
const absaetze: string[] = []
|
||||
|
||||
const imFenster = quellen.kontakte.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
|
||||
const mitIhr = imFenster
|
||||
.filter((k) => k.mitBetreuter)
|
||||
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
|
||||
const mitDritten = imFenster.filter((k) => !k.mitBetreuter)
|
||||
|
||||
absaetze.push(
|
||||
`Der Berichtszeitraum umfasst die Zeit vom ${formatDatum(zeitraumVon)} bis zum ` +
|
||||
`${formatDatum(zeitraumBis)}, also ${tageZwischen(zeitraumVon, zeitraumBis)} Tage.`,
|
||||
)
|
||||
|
||||
if (mitIhr.length === 0) {
|
||||
absaetze.push(
|
||||
`Für diesen Zeitraum ist kein persönlicher Kontakt zu ${name} protokolliert. Das entspricht ` +
|
||||
`nicht der Pflicht aus § 1863 Abs. 2 BGB, die betreute Person in dem erforderlichen Umfang ` +
|
||||
`persönlich aufzusuchen. Bitte prüfe, ob Kontakte nur nicht erfasst wurden, und trage sie ` +
|
||||
`nach — ${ERGAENZEN}.`,
|
||||
)
|
||||
} else {
|
||||
const nachArt = haeufigkeit(mitIhr.map((k) => k.art))
|
||||
const teile = [...nachArt.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([art, anzahl]) =>
|
||||
anzahl === 1
|
||||
? `ein ${KONTAKTART_EINZAHL[art]}`
|
||||
: `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
|
||||
)
|
||||
|
||||
absaetze.push(
|
||||
`Ich hatte im Berichtszeitraum ${malAngabe(mitIhr.length)} persönlichen Kontakt zu ${name}: ` +
|
||||
`${aufzaehlung(teile)}.`,
|
||||
)
|
||||
|
||||
const abstand = Math.round(tageZwischen(zeitraumVon, zeitraumBis) / mitIhr.length)
|
||||
const luecken = mitIhr
|
||||
.slice(1)
|
||||
.map((k, i) => tageZwischen(mitIhr[i]!.datum, k.datum))
|
||||
const groessteLuecke = luecken.length > 0 ? Math.max(...luecken) : 0
|
||||
const rhythmus = [`Im Mittel entspricht das einem Kontakt alle ${abstand} Tage.`]
|
||||
if (groessteLuecke > 0) {
|
||||
rhythmus.push(
|
||||
`Die längste Zeitspanne zwischen zwei persönlichen Kontakten betrug ${groessteLuecke} Tage.`,
|
||||
)
|
||||
}
|
||||
rhythmus.push(
|
||||
`Der erste Kontakt im Berichtszeitraum war am ${formatDatum(mitIhr[0]!.datum)}, ` +
|
||||
`der letzte am ${formatDatum(mitIhr[mitIhr.length - 1]!.datum)}.`,
|
||||
)
|
||||
absaetze.push(rhythmus.join(' '))
|
||||
|
||||
const minuten = mitIhr.reduce((summe, k) => summe + (k.dauerMinuten ?? 0), 0)
|
||||
if (minuten > 0) {
|
||||
absaetze.push(
|
||||
`Für diese Kontakte sind insgesamt ${dauerAusgeschrieben(minuten)} erfasst. Besprochen ` +
|
||||
`wurden dabei ${betreute.aufgabenkreise.length > 0 ? 'die Angelegenheiten aus meinen Aufgabenkreisen' : 'die laufenden Angelegenheiten'}, ` +
|
||||
`das Befinden und die persönliche Lage von ${name}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (mitDritten.length > 0) {
|
||||
const nachArt = haeufigkeit(mitDritten.map((k) => k.art))
|
||||
const teile = [...nachArt.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([art, anzahl]) =>
|
||||
anzahl === 1 ? `ein ${KONTAKTART_EINZAHL[art]}` : `${anzahl} ${KONTAKTART_MEHRZAHL[art]}`,
|
||||
)
|
||||
const stellen = [...new Set(mitDritten.map((k) => k.gespraechspartner).filter(Boolean))] as string[]
|
||||
const wer = stellen.length > 0 ? ` Beteiligt waren dabei ${aufzaehlung(stellen.slice(0, 8))}.` : ''
|
||||
absaetze.push(
|
||||
`Hinzu kommen ${mitDritten.length} Kontakte mit Dritten in ihren Angelegenheiten — ` +
|
||||
`${aufzaehlung(teile)}.${wer}`,
|
||||
)
|
||||
}
|
||||
|
||||
return absaetze
|
||||
}
|
||||
|
||||
function abschnittWuensche(quellen: JahresberichtQuellen): string[] {
|
||||
const { betreute, zeitraumVon, zeitraumBis } = quellen
|
||||
const name = kurzName(betreute)
|
||||
const absaetze: string[] = []
|
||||
|
||||
absaetze.push(
|
||||
`Nach § 1821 BGB sind die Wünsche der betreuten Person Maßstab meines Handelns. Ich habe ` +
|
||||
`${name} bei jedem Kontakt nach ihren Wünschen gefragt und sie im Kontaktprotokoll ` +
|
||||
`festgehalten.`,
|
||||
)
|
||||
|
||||
const mitWunsch = quellen.kontakte
|
||||
.filter((k) => imZeitraum(k.datum, zeitraumVon, zeitraumBis))
|
||||
.filter((k) => (k.wuensche ?? '').trim() !== '')
|
||||
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
|
||||
|
||||
if (mitWunsch.length === 0) {
|
||||
absaetze.push(
|
||||
`Für den Berichtszeitraum sind keine ausdrücklich geäußerten Wünsche festgehalten. ` +
|
||||
`${ERGAENZEN}: Was hat ${name} sich gewünscht, und wie hast du darauf reagiert? Auch ein ` +
|
||||
`Wunsch, dem nicht entsprochen werden konnte, gehört in den Bericht — zusammen mit dem Grund.`,
|
||||
)
|
||||
} else {
|
||||
absaetze.push(
|
||||
`Im Berichtszeitraum sind ${mitWunsch.length} geäußerte Wünsche festgehalten:`,
|
||||
)
|
||||
for (const kontakt of mitWunsch) {
|
||||
absaetze.push(
|
||||
`• ${formatDatum(kontakt.datum)} (${KONTAKTART_EINZAHL[kontakt.art]}): ` +
|
||||
`${kontakt.wuensche!.trim().replace(/\s+/g, ' ')}`,
|
||||
)
|
||||
}
|
||||
absaetze.push(
|
||||
`Diesen Wünschen bin ich wie folgt nachgekommen: ${ERGAENZEN}. Wo ich einem Wunsch nicht ` +
|
||||
`entsprechen konnte, ist der Grund hier zu nennen (§ 1821 Abs. 3 BGB).`,
|
||||
)
|
||||
}
|
||||
|
||||
if (betreute.ermittelterWille && betreute.ermittelterWille.trim() !== '') {
|
||||
absaetze.push(
|
||||
`Zum ermittelten Willen von ${name} ist in den Stammdaten festgehalten: ` +
|
||||
`${betreute.ermittelterWille.trim().replace(/\s+/g, ' ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
return absaetze
|
||||
}
|
||||
|
||||
function abschnittAngelegenheiten(quellen: JahresberichtQuellen): string[] {
|
||||
const { zeitraumVon, zeitraumBis } = quellen
|
||||
const absaetze: string[] = []
|
||||
|
||||
if (quellen.vorgaenge.length === 0) {
|
||||
absaetze.push(
|
||||
`Für den Berichtszeitraum sind keine Vorgänge erfasst. ${ERGAENZEN}: Welche Angelegenheiten ` +
|
||||
`hast du bearbeitet?`,
|
||||
)
|
||||
return absaetze
|
||||
}
|
||||
|
||||
const erledigt = quellen.vorgaenge.filter(
|
||||
(v) => v.status === 'ERLEDIGT' && (v.erledigtAm === null || imZeitraum(v.erledigtAm, zeitraumVon, zeitraumBis)),
|
||||
)
|
||||
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
|
||||
|
||||
absaetze.push(
|
||||
`Im Berichtszeitraum habe ich ${quellen.vorgaenge.length} Angelegenheiten geführt; davon sind ` +
|
||||
`${erledigt.length} abgeschlossen und ${laufend.length} noch offen.`,
|
||||
)
|
||||
|
||||
const kategorien = [...new Set(quellen.vorgaenge.map((v) => v.kategorie))].sort((a, b) =>
|
||||
KATEGORIE_BESCHRIFTUNG[a].localeCompare(KATEGORIE_BESCHRIFTUNG[b], 'de'),
|
||||
)
|
||||
|
||||
for (const kategorie of kategorien) {
|
||||
const fertig = erledigt.filter((v) => v.kategorie === kategorie)
|
||||
const offen = laufend.filter((v) => v.kategorie === kategorie)
|
||||
const saetze: string[] = []
|
||||
|
||||
if (fertig.length > 0) {
|
||||
const titel = fertig.map((v) =>
|
||||
v.erledigtAm ? `${v.titel} (abgeschlossen am ${formatDatum(v.erledigtAm)})` : v.titel,
|
||||
)
|
||||
saetze.push(`Abgeschlossen wurde ${aufzaehlung(titel)}.`)
|
||||
}
|
||||
if (offen.length > 0) {
|
||||
const titel = offen.map((v) => {
|
||||
const schritt = v.naechsterSchritt ? `; nächster Schritt: ${v.naechsterSchritt}` : ''
|
||||
const wer = v.gegenstelle ? `, Gegenstelle ${v.gegenstelle}` : ''
|
||||
return `${v.titel}${wer}${schritt}`
|
||||
})
|
||||
saetze.push(`Noch offen ist ${aufzaehlung(titel)}.`)
|
||||
}
|
||||
|
||||
absaetze.push(`${KATEGORIE_BESCHRIFTUNG[kategorie]}: ${saetze.join(' ')}`)
|
||||
}
|
||||
|
||||
const genehmigungspflichtig = quellen.vorgaenge.filter((v) => v.genehmigungspflichtig)
|
||||
if (genehmigungspflichtig.length > 0) {
|
||||
absaetze.push(
|
||||
`Genehmigungspflichtig waren dabei ${aufzaehlung(genehmigungspflichtig.map((v) => v.titel))}. ` +
|
||||
`Zum Stand der jeweiligen betreuungsgerichtlichen Genehmigung: ${ERGAENZEN}.`,
|
||||
)
|
||||
}
|
||||
|
||||
return absaetze
|
||||
}
|
||||
|
||||
function abschnittEinschaetzung(quellen: JahresberichtQuellen): string[] {
|
||||
const { betreute } = quellen
|
||||
const name = kurzName(betreute)
|
||||
const absaetze: string[] = []
|
||||
|
||||
const kreise = betreute.aufgabenkreise.map((k) => AUFGABENKREIS_BESCHRIFTUNG[k])
|
||||
if (betreute.aufgabenkreiseFreitext && betreute.aufgabenkreiseFreitext.trim() !== '') {
|
||||
kreise.push(betreute.aufgabenkreiseFreitext.trim())
|
||||
}
|
||||
|
||||
absaetze.push(
|
||||
kreise.length > 0
|
||||
? `Die Betreuung besteht derzeit für die Aufgabenkreise ${aufzaehlung(kreise)}.`
|
||||
: `Die Aufgabenkreise sind in dieser Anwendung nicht hinterlegt (${ERGAENZEN}).`,
|
||||
)
|
||||
|
||||
const laufend = quellen.vorgaenge.filter((v) => v.status !== 'ERLEDIGT')
|
||||
const begruendung =
|
||||
laufend.length > 0
|
||||
? `Es sind weiterhin ${laufend.length} Angelegenheiten in Bearbeitung, insbesondere ` +
|
||||
`${aufzaehlung(laufend.slice(0, 3).map((v) => v.titel))}. Ein Wegfall des Betreuungsbedarfs ` +
|
||||
`ist daher nicht erkennbar.`
|
||||
: `Zurzeit sind keine offenen Angelegenheiten verzeichnet.`
|
||||
|
||||
absaetze.push(
|
||||
`Nach meiner Einschätzung ist die Betreuung in den bisherigen Aufgabenkreisen unverändert ` +
|
||||
`fortzuführen. ${begruendung}`,
|
||||
)
|
||||
|
||||
absaetze.push(
|
||||
`Ein Grund, die Betreuung zu erweitern, ist mir nicht bekannt; ebenso wenig ein Grund, sie ` +
|
||||
`einzuschränken oder aufzuheben. Sollte das für dich anders sein, ersetze diesen Absatz: ` +
|
||||
`${ERGAENZEN} — fortzuführen, zu erweitern (um welchen Aufgabenkreis und warum), ` +
|
||||
`einzuschränken oder aufzuheben.`,
|
||||
)
|
||||
|
||||
absaetze.push(
|
||||
`${name} ist mit der Fortführung der Betreuung durch mich einverstanden. ${ERGAENZEN}, ` +
|
||||
`falls sich das geändert hat oder der Wille nicht sicher zu ermitteln ist.`,
|
||||
)
|
||||
|
||||
return absaetze
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Zusammenbau
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Baut den Entwurf aus den Quelldaten. Reine Funktion: gleiche Eingabe,
|
||||
* gleicher Text - damit sich der Entwurf testen laesst.
|
||||
*/
|
||||
export function jahresberichtEntwurfBauen(quellen: JahresberichtQuellen): Berichtsabschnitt[] {
|
||||
const bauer = [
|
||||
abschnittPersoenlicheVerhaeltnisse,
|
||||
abschnittKontakt,
|
||||
abschnittWuensche,
|
||||
abschnittAngelegenheiten,
|
||||
abschnittEinschaetzung,
|
||||
]
|
||||
|
||||
return GLIEDERUNG.map((eintrag, i) => ({
|
||||
nummer: eintrag.nummer,
|
||||
titel: eintrag.titel,
|
||||
rechtsgrundlage: eintrag.rechtsgrundlage,
|
||||
absaetze: bauer[i]!(quellen).filter((absatz) => absatz.trim() !== ''),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Entwurf als reiner Text - so wird er in Bericht.entwurfVerschluesselt
|
||||
* abgelegt und im Browser bearbeitet. Ueberschriften tragen ihre Nummer,
|
||||
* damit sich der Text spaeter wieder in Abschnitte zerlegen laesst.
|
||||
*/
|
||||
export function entwurfAlsText(abschnitte: Berichtsabschnitt[]): string {
|
||||
return abschnitte
|
||||
.map((abschnitt) =>
|
||||
[`${abschnitt.nummer}. ${abschnitt.titel}`, '', ...abschnitt.absaetze].join('\n'),
|
||||
)
|
||||
.join('\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Direktweg von den Quellen zum Text. */
|
||||
export function jahresberichtEntwurfText(quellen: JahresberichtQuellen): string {
|
||||
return entwurfAlsText(jahresberichtEntwurfBauen(quellen))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gegenstueck zu entwurfAlsText: zerlegt den (moeglicherweise vom Betreuer
|
||||
* ueberarbeiteten) Text wieder in Abschnitte. Alles vor der ersten
|
||||
* nummerierten Ueberschrift landet in einem Vorspann ohne Titel - so geht
|
||||
* auch dann nichts verloren, wenn jemand oben etwas eingefuegt hat.
|
||||
*/
|
||||
export function textZuAbschnitten(text: string): { titel: string | null; absaetze: string[] }[] {
|
||||
const zeilen = text.replace(/\r\n/g, '\n').split('\n')
|
||||
const abschnitte: { titel: string | null; absaetze: string[] }[] = []
|
||||
let aktuell: { titel: string | null; absaetze: string[] } = { titel: null, absaetze: [] }
|
||||
|
||||
for (const zeile of zeilen) {
|
||||
if (/^\s*\d+\.\s+\S/.test(zeile)) {
|
||||
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
|
||||
aktuell = { titel: zeile.trim(), absaetze: [] }
|
||||
continue
|
||||
}
|
||||
if (zeile.trim() === '') continue
|
||||
aktuell.absaetze.push(zeile.trim())
|
||||
}
|
||||
if (aktuell.titel !== null || aktuell.absaetze.length > 0) abschnitte.push(aktuell)
|
||||
|
||||
return abschnitte
|
||||
}
|
||||
|
||||
/** Enthaelt der Entwurf noch Stellen, die ausgefuellt werden muessen? */
|
||||
export function offeneErgaenzungen(text: string): number {
|
||||
return text.split(ERGAENZEN).length - 1
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'server-only'
|
||||
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
|
||||
import { Berichtsseite, type Berichtskopf } from '@/lib/pdf/rahmen'
|
||||
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { oeffnen } from '@/lib/crypto/feld'
|
||||
// Die Endung steht hier absichtlich: Neben dieser Datei liegt
|
||||
// jahresbericht.ts mit den reinen Textbausteinen. Ein Import ohne Endung
|
||||
// waere mehrdeutig - Webpack probiert ".tsx" vor ".ts" und wuerde diese
|
||||
// Datei sich selbst importieren, TypeScript und Vitest wuerden die ".ts"
|
||||
// nehmen. Mit ausgeschriebener Endung greifen alle drei dieselbe Datei.
|
||||
// Damit trotzdem jeder Aufrufer alles unter einem Namen findet, wird die
|
||||
// reine Schnittstelle unten vollstaendig weitergereicht.
|
||||
import {
|
||||
jahresberichtEntwurfText,
|
||||
textZuAbschnitten,
|
||||
type JahresberichtQuellen,
|
||||
} from './jahresbericht.ts'
|
||||
|
||||
export * from './jahresbericht.ts'
|
||||
|
||||
/**
|
||||
* Jahresbericht - Datenbeschaffung und PDF.
|
||||
*
|
||||
* Die Textbausteine stehen in jahresbericht.ts und kommen ohne Datenbank
|
||||
* aus. Hier wird nur eingesammelt und gesetzt. Wichtig: Was ins PDF geht,
|
||||
* ist IMMER der gespeicherte, vom Betreuer ueberarbeitete Entwurfstext -
|
||||
* nie ein frisch berechneter. Sonst stuende im abgegebenen Bericht etwas
|
||||
* anderes als das, was auf dem Bildschirm zur Freigabe stand.
|
||||
*/
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Quelldaten aus der Datenbank
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sammelt alles, woraus der Entwurf gebaut wird. Der Aufrufer hat die
|
||||
* Zugriffsberechtigung bereits geprueft (erfordereZugang) - diese Funktion
|
||||
* fragt nicht noch einmal, sie eng auf die betreuteId zu begrenzen ist
|
||||
* aber Pflicht.
|
||||
*/
|
||||
export async function jahresberichtQuellenLaden(
|
||||
betreuteId: string,
|
||||
zeitraumVon: Date,
|
||||
zeitraumBis: Date,
|
||||
): Promise<JahresberichtQuellen> {
|
||||
const [roheBetreute, kontakte, gesundheit, vorgaenge] = await Promise.all([
|
||||
db.betreute.findFirstOrThrow({ where: { id: betreuteId, ...nichtGeloescht } }),
|
||||
db.kontaktprotokoll.findMany({
|
||||
where: {
|
||||
betreuteId,
|
||||
datum: { gte: zeitraumVon, lte: zeitraumBis },
|
||||
...nichtGeloescht,
|
||||
},
|
||||
orderBy: { datum: 'asc' },
|
||||
}),
|
||||
db.gesundheitsereignis.findMany({
|
||||
where: {
|
||||
betreuteId,
|
||||
datum: { gte: zeitraumVon, lte: zeitraumBis },
|
||||
...nichtGeloescht,
|
||||
},
|
||||
orderBy: { datum: 'asc' },
|
||||
}),
|
||||
db.vorgang.findMany({
|
||||
where: {
|
||||
betreuteId,
|
||||
...nichtGeloescht,
|
||||
// Was vor dem Berichtszeitraum abgeschlossen wurde, gehoert in den
|
||||
// Bericht des Vorjahres - hier waere es nur Ballast.
|
||||
OR: [{ erledigtAm: null }, { erledigtAm: { gte: zeitraumVon } }],
|
||||
},
|
||||
orderBy: [{ kategorie: 'asc' }, { titel: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const betreute = oeffnen('Betreute', roheBetreute)
|
||||
|
||||
return {
|
||||
betreute: {
|
||||
anrede: betreute.anrede,
|
||||
vorname: betreute.vorname,
|
||||
nachname: betreute.nachname,
|
||||
geburtsdatum: betreute.geburtsdatum,
|
||||
heimName: betreute.heimName,
|
||||
heimStrasse: betreute.heimStrasse,
|
||||
heimPlz: betreute.heimPlz,
|
||||
heimOrt: betreute.heimOrt,
|
||||
heimZimmer: betreute.heimZimmer,
|
||||
heimEinzugAm: betreute.heimEinzugAm,
|
||||
fruehereStrasse: betreute.fruehereStrasse,
|
||||
frueherePlz: betreute.frueherePlz,
|
||||
fruehererOrt: betreute.fruehererOrt,
|
||||
pflegegrad: betreute.pflegegrad,
|
||||
pflegegradSeit: betreute.pflegegradSeit,
|
||||
schwerbehinderungGdB: betreute.schwerbehinderungGdB,
|
||||
schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
|
||||
diagnosen: betreute.diagnosen,
|
||||
ermittelterWille: betreute.ermittelterWille,
|
||||
patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
|
||||
vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
|
||||
aufgabenkreise: betreute.aufgabenkreise,
|
||||
aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
|
||||
betreuungsgericht: betreute.betreuungsgericht,
|
||||
aktenzeichenGericht: betreute.aktenzeichenGericht,
|
||||
bestellungAm: betreute.bestellungAm,
|
||||
bestellungBis: betreute.bestellungBis,
|
||||
},
|
||||
zeitraumVon,
|
||||
zeitraumBis,
|
||||
kontakte: kontakte.map((k) => {
|
||||
const offen = oeffnen('Kontaktprotokoll', k)
|
||||
return {
|
||||
datum: k.datum,
|
||||
art: k.art,
|
||||
mitBetreuter: k.mitBetreuter,
|
||||
dauerMinuten: k.dauerMinuten,
|
||||
gespraechspartner: k.gespraechspartner,
|
||||
wuensche: offen.wuensche,
|
||||
}
|
||||
}),
|
||||
gesundheit: gesundheit.map((e) => ({
|
||||
datum: e.datum,
|
||||
bisDatum: e.bisDatum,
|
||||
art: e.art,
|
||||
titel: e.titel,
|
||||
einrichtung: e.einrichtung,
|
||||
abwesenheitstage: e.abwesenheitstage,
|
||||
})),
|
||||
vorgaenge: vorgaenge.map((v) => ({
|
||||
titel: v.titel,
|
||||
kategorie: v.kategorie,
|
||||
status: v.status,
|
||||
naechsterSchritt: v.naechsterSchritt,
|
||||
gegenstelle: v.gegenstelle,
|
||||
genehmigungspflichtig: v.genehmigungspflichtig,
|
||||
erledigtAm: v.erledigtAm,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Entwurfstext direkt aus der Datenbank - fuer "Entwurf neu berechnen". */
|
||||
export async function jahresberichtEntwurfAusDatenbank(
|
||||
betreuteId: string,
|
||||
zeitraumVon: Date,
|
||||
zeitraumBis: Date,
|
||||
): Promise<string> {
|
||||
return jahresberichtEntwurfText(
|
||||
await jahresberichtQuellenLaden(betreuteId, zeitraumVon, zeitraumBis),
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PDF
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const stile = StyleSheet.create({
|
||||
ueberschrift: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginTop: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
absatz: {
|
||||
fontSize: 10,
|
||||
lineHeight: 1.5,
|
||||
marginBottom: 6,
|
||||
textAlign: 'justify',
|
||||
},
|
||||
aufzaehlung: {
|
||||
fontSize: 10,
|
||||
lineHeight: 1.5,
|
||||
marginBottom: 3,
|
||||
marginLeft: 12,
|
||||
},
|
||||
vorspann: {
|
||||
fontSize: 9,
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 10,
|
||||
color: '#444444',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Setzt den Entwurfstext. Ueberschriften erkennt der Satz an der
|
||||
* fuehrenden Nummer ("2. Persoenlicher Kontakt"), Aufzaehlungen am
|
||||
* Aufzaehlungszeichen - genau das Format, das entwurfAlsText() erzeugt und
|
||||
* das sich im Textfeld auch von Hand fortschreiben laesst.
|
||||
*/
|
||||
export function JahresberichtInhalt({ text }: { text: string }) {
|
||||
const abschnitte = textZuAbschnitten(text)
|
||||
|
||||
return (
|
||||
<View>
|
||||
{abschnitte.map((abschnitt, i) => (
|
||||
<View key={`abschnitt-${i}`} wrap={true}>
|
||||
{abschnitt.titel && <Text style={stile.ueberschrift}>{abschnitt.titel}</Text>}
|
||||
{abschnitt.absaetze.map((absatz, j) => (
|
||||
<Text
|
||||
key={`absatz-${i}-${j}`}
|
||||
style={absatz.startsWith('•') ? stile.aufzaehlung : stile.absatz}
|
||||
>
|
||||
{absatz}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function JahresberichtDokument({
|
||||
kopf,
|
||||
text,
|
||||
vorspann,
|
||||
}: {
|
||||
kopf: Berichtskopf
|
||||
text: string
|
||||
vorspann?: string
|
||||
}) {
|
||||
return (
|
||||
<Document title={kopf.titel} author={kopf.erstelltVon} language="de">
|
||||
<Berichtsseite kopf={kopf} mitUnterschrift={true}>
|
||||
{vorspann && <Text style={stile.vorspann}>{vorspann}</Text>}
|
||||
<JahresberichtInhalt text={text} />
|
||||
</Berichtsseite>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
|
||||
/** Rendert den freigegebenen Entwurfstext zu einem PDF-Buffer. */
|
||||
export function jahresberichtPdf(kopf: Berichtskopf, text: string): Promise<Buffer> {
|
||||
return pdfErzeugen(<JahresberichtDokument kopf={kopf} text={text} />)
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import 'server-only'
|
||||
import * as React from 'react'
|
||||
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
|
||||
import { Berichtsseite } from '@/lib/pdf/rahmen'
|
||||
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
|
||||
import { formatCent, formatDatum } from '@/lib/utils'
|
||||
import { RICHTUNG_BESCHRIFTUNG, VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
|
||||
import { VARIANTE_RECHTSGRUNDLAGE } from '@/lib/finanzen/rechnungslegung'
|
||||
import type { RechnungslegungDaten } from '@/lib/berichte/gemeinsam'
|
||||
|
||||
/**
|
||||
* Vermoegensuebersicht (§ 1859 BGB) und Rechnungslegung (§ 1865 BGB).
|
||||
*
|
||||
* Ein Dokument, zwei Auspraegungen:
|
||||
*
|
||||
* - Befreite Betreuer - Ehegatten, Lebenspartner, Eltern, Kinder und
|
||||
* Geschwister - schulden nach § 1859 BGB nur eine Uebersicht ueber den
|
||||
* Bestand des Vermoegens. Das ist hier der Regelfall.
|
||||
* - Alle uebrigen Betreuer legen nach § 1865 BGB vollstaendig Rechnung:
|
||||
* jede Einnahme, jede Ausgabe, jeder Beleg mit laufender Nummer.
|
||||
*
|
||||
* Beide Varianten enthalten dieselbe Rechenprobe, und beide weisen eine
|
||||
* Abweichung offen aus. Der Gedanke dahinter: Ein Bericht, in dem eine
|
||||
* Differenz stillschweigend verschwindet, ist eine falsche Erklaerung
|
||||
* gegenueber dem Gericht - eine, die man selbst unterschreibt. Ein
|
||||
* Bericht, der die Differenz benennt, ist eine ehrliche Zwischenmeldung.
|
||||
*/
|
||||
|
||||
const stile = StyleSheet.create({
|
||||
abschnitt: { marginTop: 14 },
|
||||
ueberschrift: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginBottom: 5,
|
||||
paddingBottom: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#333333',
|
||||
},
|
||||
untertitel: { fontSize: 9.5, fontFamily: 'Helvetica-Bold', marginTop: 8, marginBottom: 3 },
|
||||
zeile: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 3,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: '#dddddd',
|
||||
},
|
||||
kopfzeile: {
|
||||
flexDirection: 'row',
|
||||
paddingBottom: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#999999',
|
||||
},
|
||||
kopftext: { fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#444444' },
|
||||
text: { fontSize: 9 },
|
||||
klein: { fontSize: 7.5, color: '#555555' },
|
||||
nummer: { width: 26, fontSize: 8 },
|
||||
datum: { width: 52, fontSize: 8 },
|
||||
breit: { flex: 1, paddingRight: 6 },
|
||||
mittel: { width: 110, paddingRight: 6 },
|
||||
schmal: { width: 46, paddingRight: 4 },
|
||||
betrag: { width: 78, textAlign: 'right', fontSize: 9 },
|
||||
summenzeile: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: 4,
|
||||
marginTop: 2,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#333333',
|
||||
},
|
||||
summentext: { flex: 1, fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
|
||||
summenbetrag: { width: 78, textAlign: 'right', fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
|
||||
probe: {
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#333333',
|
||||
},
|
||||
probeStimmt: { borderColor: '#15803d', backgroundColor: '#f0fdf4' },
|
||||
probeFehlt: { borderColor: '#b91c1c', backgroundColor: '#fef2f2' },
|
||||
probezeile: { flexDirection: 'row', paddingVertical: 2 },
|
||||
probetext: { flex: 1, fontSize: 9 },
|
||||
probebetrag: { width: 90, textAlign: 'right', fontSize: 9 },
|
||||
hinweisblock: {
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#b45309',
|
||||
backgroundColor: '#fffbeb',
|
||||
},
|
||||
hinweistext: { fontSize: 8, marginBottom: 3 },
|
||||
erklaerung: { fontSize: 8, color: '#555555', marginBottom: 6 },
|
||||
fett: { fontFamily: 'Helvetica-Bold' },
|
||||
})
|
||||
|
||||
function Summenzeile({ text, betragCent }: { text: string; betragCent: bigint }) {
|
||||
return (
|
||||
<View style={stile.summenzeile}>
|
||||
<Text style={stile.summentext}>{text}</Text>
|
||||
<Text style={stile.summenbetrag}>{formatCent(betragCent)}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function RechnungslegungDokument({
|
||||
daten,
|
||||
}: {
|
||||
daten: RechnungslegungDaten
|
||||
}): React.ReactElement {
|
||||
const { rechnung, jahr } = daten
|
||||
const voll = rechnung.variante === 'VOLL_1865'
|
||||
const grundlage = VARIANTE_RECHTSGRUNDLAGE[rechnung.variante]
|
||||
|
||||
return (
|
||||
<Document
|
||||
title={`${daten.kopf.titel} ${daten.betreute.name}`}
|
||||
author={daten.kopf.erstelltVon}
|
||||
subject={`${daten.kopf.titel} nach ${grundlage}`}
|
||||
>
|
||||
<Berichtsseite kopf={daten.kopf} mitUnterschrift>
|
||||
<Text style={stile.erklaerung}>
|
||||
{voll
|
||||
? 'Vollständige Rechnungslegung nach § 1865 BGB über die Verwaltung des Vermögens der ' +
|
||||
'betreuten Person. Die Rechnung weist Anfangsbestand, sämtliche Einnahmen und ' +
|
||||
'Ausgaben sowie den Endbestand aus; die Belege sind fortlaufend nummeriert und ' +
|
||||
'werden in dieser Reihenfolge beigefügt.'
|
||||
: 'Vermögensübersicht nach § 1859 BGB. Als befreiter Betreuer ist über den Bestand ' +
|
||||
'des Vermögens und dessen Veränderung im Betreuungsjahr zu berichten; eine ' +
|
||||
'vollständige Rechnungslegung mit Einzelbelegen nach § 1865 BGB ist nicht ' +
|
||||
'geschuldet. Die Belege werden gleichwohl aufbewahrt und können vorgelegt werden.'}
|
||||
</Text>
|
||||
<Text style={stile.erklaerung}>
|
||||
Berichtszeitraum: {jahr.bezeichnung}. Das Betreuungsjahr läuft ab dem Tag der Bestellung
|
||||
{daten.betreute.bestellungAm
|
||||
? ` (${formatDatum(daten.betreute.bestellungAm)})`
|
||||
: ''}
|
||||
, nicht ab dem Kalenderjahr.
|
||||
</Text>
|
||||
|
||||
{rechnung.zwischenaufstellung && (
|
||||
<View style={stile.hinweisblock}>
|
||||
<Text style={[stile.hinweistext, stile.fett]}>Zwischenaufstellung</Text>
|
||||
<Text style={stile.hinweistext}>
|
||||
Das Betreuungsjahr ist noch nicht abgelaufen. Diese Aufstellung gibt den Stand zum
|
||||
Zeitpunkt der Erstellung wieder und ersetzt die Rechnungslegung nach Ablauf des
|
||||
Jahres nicht.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>A. Bestände</Text>
|
||||
<View style={stile.kopfzeile}>
|
||||
<Text style={[stile.kopftext, stile.breit]}>Konto</Text>
|
||||
<Text style={[stile.kopftext, stile.mittel]}>Bank / IBAN</Text>
|
||||
<Text style={[stile.kopftext, stile.betrag]}>Anfang</Text>
|
||||
<Text style={[stile.kopftext, stile.betrag]}>Ende</Text>
|
||||
</View>
|
||||
{rechnung.bestaende.length === 0 ? (
|
||||
<Text style={stile.text}>Keine Konten erfasst.</Text>
|
||||
) : (
|
||||
rechnung.bestaende.map((konto) => (
|
||||
<View key={konto.kontoId} style={stile.zeile}>
|
||||
<View style={stile.breit}>
|
||||
<Text style={stile.text}>{konto.bezeichnung}</Text>
|
||||
<Text style={stile.klein}>{konto.artBeschriftung}</Text>
|
||||
</View>
|
||||
<View style={stile.mittel}>
|
||||
<Text style={stile.klein}>{konto.bank ?? '–'}</Text>
|
||||
{konto.ibanMaskiert && <Text style={stile.klein}>{konto.ibanMaskiert}</Text>}
|
||||
</View>
|
||||
<View style={stile.betrag}>
|
||||
<Text style={stile.text}>{formatCent(konto.anfangCent)}</Text>
|
||||
<Text style={stile.klein}>{formatDatum(konto.anfangStichtag)}</Text>
|
||||
</View>
|
||||
<View style={stile.betrag}>
|
||||
<Text style={stile.text}>{formatCent(konto.endeCent)}</Text>
|
||||
<Text style={stile.klein}>{formatDatum(konto.endeStichtag)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
<Summenzeile
|
||||
text={`Anfangsbestand am ${formatDatum(rechnung.von)}`}
|
||||
betragCent={rechnung.anfangsbestandCent}
|
||||
/>
|
||||
<Summenzeile
|
||||
text={`Endbestand am ${formatDatum(rechnung.bis)}`}
|
||||
betragCent={rechnung.endbestandCent}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>B. Einnahmen</Text>
|
||||
{rechnung.einnahmen.length === 0 ? (
|
||||
<Text style={stile.text}>Im Berichtszeitraum sind keine Einnahmen erfasst.</Text>
|
||||
) : (
|
||||
rechnung.einnahmen.map((gruppe) => (
|
||||
<View key={gruppe.kategorie} wrap={false}>
|
||||
<View style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit, voll ? stile.fett : {}]}>
|
||||
{gruppe.beschriftung}
|
||||
</Text>
|
||||
<Text style={[stile.klein, stile.schmal]}>{gruppe.anzahl}</Text>
|
||||
<Text style={[stile.betrag, voll ? stile.fett : {}]}>
|
||||
{formatCent(gruppe.summeCent)}
|
||||
</Text>
|
||||
</View>
|
||||
{voll &&
|
||||
gruppe.buchungen.map((buchung) => (
|
||||
<View key={buchung.id} style={stile.zeile}>
|
||||
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
|
||||
<View style={stile.breit}>
|
||||
<Text style={stile.text}>{buchung.gegenstelle ?? 'ohne Angabe'}</Text>
|
||||
{buchung.verwendungszweck && (
|
||||
<Text style={stile.klein}>{buchung.verwendungszweck}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
<Summenzeile text="Summe Einnahmen" betragCent={rechnung.summeEinnahmenCent} />
|
||||
{rechnung.vermoegenswirksamEinnahmenCent !== 0n && (
|
||||
<Text style={stile.klein}>
|
||||
davon vermögenswirksam (kein laufendes Einkommen):{' '}
|
||||
{formatCent(rechnung.vermoegenswirksamEinnahmenCent)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>C. Ausgaben</Text>
|
||||
{rechnung.ausgaben.length === 0 ? (
|
||||
<Text style={stile.text}>Im Berichtszeitraum sind keine Ausgaben erfasst.</Text>
|
||||
) : (
|
||||
rechnung.ausgaben.map((gruppe) => (
|
||||
<View key={gruppe.kategorie} wrap={false}>
|
||||
<View style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit, voll ? stile.fett : {}]}>
|
||||
{gruppe.beschriftung}
|
||||
</Text>
|
||||
<Text style={[stile.klein, stile.schmal]}>{gruppe.anzahl}</Text>
|
||||
<Text style={[stile.betrag, voll ? stile.fett : {}]}>
|
||||
{formatCent(gruppe.summeCent)}
|
||||
</Text>
|
||||
</View>
|
||||
{voll &&
|
||||
gruppe.buchungen.map((buchung) => (
|
||||
<View key={buchung.id} style={stile.zeile}>
|
||||
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
|
||||
<View style={stile.breit}>
|
||||
<Text style={stile.text}>{buchung.gegenstelle ?? 'ohne Angabe'}</Text>
|
||||
{buchung.verwendungszweck && (
|
||||
<Text style={stile.klein}>{buchung.verwendungszweck}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
<Summenzeile text="Summe Ausgaben" betragCent={rechnung.summeAusgabenCent} />
|
||||
{rechnung.vermoegenswirksamAusgabenCent !== 0n && (
|
||||
<Text style={stile.klein}>
|
||||
davon vermögenswirksam: {formatCent(rechnung.vermoegenswirksamAusgabenCent)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View
|
||||
style={[stile.probe, rechnung.probe.stimmt ? stile.probeStimmt : stile.probeFehlt]}
|
||||
wrap={false}
|
||||
>
|
||||
<Text style={[stile.probetext, stile.fett, { marginBottom: 4 }]}>D. Rechenprobe</Text>
|
||||
<View style={stile.probezeile}>
|
||||
<Text style={stile.probetext}>Anfangsbestand</Text>
|
||||
<Text style={stile.probebetrag}>{formatCent(rechnung.probe.anfangsbestandCent)}</Text>
|
||||
</View>
|
||||
<View style={stile.probezeile}>
|
||||
<Text style={stile.probetext}>zuzüglich Einnahmen</Text>
|
||||
<Text style={stile.probebetrag}>{formatCent(rechnung.probe.einnahmenCent)}</Text>
|
||||
</View>
|
||||
<View style={stile.probezeile}>
|
||||
<Text style={stile.probetext}>abzüglich Ausgaben</Text>
|
||||
<Text style={stile.probebetrag}>{formatCent(-rechnung.probe.ausgabenCent)}</Text>
|
||||
</View>
|
||||
<View style={[stile.probezeile, { borderTopWidth: 1, borderTopColor: '#333333', paddingTop: 3 }]}>
|
||||
<Text style={[stile.probetext, stile.fett]}>rechnerischer Endbestand</Text>
|
||||
<Text style={[stile.probebetrag, stile.fett]}>
|
||||
{formatCent(rechnung.probe.rechnerischerEndbestandCent)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={stile.probezeile}>
|
||||
<Text style={stile.probetext}>Endbestand laut Saldoständen</Text>
|
||||
<Text style={stile.probebetrag}>
|
||||
{formatCent(rechnung.probe.gebuchterEndbestandCent)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[stile.probezeile, { borderTopWidth: 1, borderTopColor: '#333333', paddingTop: 3 }]}>
|
||||
<Text style={[stile.probetext, stile.fett]}>
|
||||
{rechnung.probe.stimmt ? 'Differenz – die Rechnung geht auf' : 'Differenz'}
|
||||
</Text>
|
||||
<Text style={[stile.probebetrag, stile.fett]}>
|
||||
{formatCent(rechnung.probe.differenzCent)}
|
||||
</Text>
|
||||
</View>
|
||||
{!rechnung.probe.stimmt && (
|
||||
<Text style={[stile.hinweistext, { marginTop: 5 }]}>
|
||||
Die Rechenprobe geht nicht auf. Die Differenz ist hier ausgewiesen und nicht
|
||||
ausgeglichen worden. Sie bedeutet, dass entweder Buchungen im Kassenbuch fehlen oder
|
||||
ein Saldostand nicht erfasst ist. Vor der Abgabe an das Gericht ist die Ursache zu
|
||||
klären.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
{rechnung.umbuchungen.length > 0 && (
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>E. Umbuchungen zwischen eigenen Konten</Text>
|
||||
<Text style={stile.erklaerung}>
|
||||
Diese Vorgänge verschieben Geld zwischen Konten der betreuten Person. Sie sind weder
|
||||
Einnahme noch Ausgabe und daher in keiner Summe oben enthalten; sie stehen hier nur
|
||||
der Vollständigkeit halber.
|
||||
</Text>
|
||||
{rechnung.umbuchungen.map((buchung) => (
|
||||
<View key={buchung.id} style={stile.zeile}>
|
||||
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
|
||||
<Text style={[stile.text, stile.breit]}>
|
||||
{buchung.verwendungszweck ?? buchung.gegenstelle ?? 'Umbuchung'}
|
||||
</Text>
|
||||
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rechnung.eigeneAuslagen.length > 0 && (
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>F. Eigene Auslagen des Betreuers</Text>
|
||||
<Text style={stile.erklaerung}>
|
||||
Aufwendungen, die der Betreuer aus eigenen Mitteln getragen hat. Sie gehören nicht
|
||||
zum Vermögen der betreuten Person und sind deshalb in den Summen oben nicht
|
||||
enthalten. Ihre Erstattung richtet sich nach §§ 1877, 1878 BGB.
|
||||
</Text>
|
||||
{rechnung.eigeneAuslagen.map((buchung) => (
|
||||
<View key={buchung.id} style={stile.zeile}>
|
||||
<Text style={stile.datum}>{formatDatum(buchung.datum)}</Text>
|
||||
<Text style={[stile.text, stile.breit]}>
|
||||
{buchung.verwendungszweck ?? buchung.gegenstelle ?? 'Auslage'}
|
||||
</Text>
|
||||
<Text style={[stile.klein, stile.schmal]}>
|
||||
{RICHTUNG_BESCHRIFTUNG[buchung.richtung]}
|
||||
</Text>
|
||||
<Text style={stile.betrag}>{formatCent(buchung.betragCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
<Summenzeile text="Summe eigene Auslagen" betragCent={rechnung.eigeneAuslagenCent} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View style={stile.abschnitt} break={voll}>
|
||||
<Text style={stile.ueberschrift}>
|
||||
{voll ? 'G. Vermögensbestand am Ende des Zeitraums' : 'E. Vermögensbestand am Ende des Zeitraums'}
|
||||
</Text>
|
||||
{daten.vermoegenEnde.aktivaGruppen.length === 0 &&
|
||||
daten.vermoegenEnde.passiva.length === 0 ? (
|
||||
<Text style={stile.text}>Keine Vermögensposten erfasst.</Text>
|
||||
) : (
|
||||
<>
|
||||
{daten.vermoegenEnde.aktivaGruppen.map((gruppe) => (
|
||||
<View key={gruppe.art} wrap={false}>
|
||||
<Text style={stile.untertitel}>
|
||||
{VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}
|
||||
</Text>
|
||||
{gruppe.posten.map((posten) => (
|
||||
<View key={posten.id} style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
|
||||
<Text style={[stile.klein, stile.mittel]}>{posten.wertquelle}</Text>
|
||||
<Text style={[stile.klein, stile.schmal]}>
|
||||
{formatDatum(posten.bewertetAm)}
|
||||
</Text>
|
||||
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
{daten.vermoegenEnde.passiva.length > 0 && (
|
||||
<>
|
||||
<Text style={stile.untertitel}>Verbindlichkeiten</Text>
|
||||
{daten.vermoegenEnde.passiva.map((posten) => (
|
||||
<View key={posten.id} style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
|
||||
<Text style={[stile.klein, stile.mittel]}>{posten.wertquelle}</Text>
|
||||
<Text style={[stile.klein, stile.schmal]}>
|
||||
{formatDatum(posten.bewertetAm)}
|
||||
</Text>
|
||||
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Summenzeile
|
||||
text="Reinvermögen aus Vermögensposten"
|
||||
betragCent={daten.vermoegenEnde.reinvermoegenCent}
|
||||
/>
|
||||
<Text style={stile.klein}>
|
||||
Kontoguthaben sind hier nicht enthalten – sie stehen unter A. Bestände.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
{voll && (
|
||||
<View style={stile.abschnitt} break>
|
||||
<Text style={stile.ueberschrift}>H. Belegverzeichnis</Text>
|
||||
<Text style={stile.erklaerung}>
|
||||
Laufende Nummerierung nach Datum. Die Belege sind in dieser Reihenfolge abgeheftet.
|
||||
</Text>
|
||||
<View style={stile.kopfzeile}>
|
||||
<Text style={[stile.kopftext, stile.nummer]}>Nr.</Text>
|
||||
<Text style={[stile.kopftext, stile.datum]}>Datum</Text>
|
||||
<Text style={[stile.kopftext, stile.breit]}>Vorgang</Text>
|
||||
<Text style={[stile.kopftext, stile.mittel]}>Beleg</Text>
|
||||
<Text style={[stile.kopftext, stile.betrag]}>Betrag</Text>
|
||||
</View>
|
||||
{rechnung.belege.map((beleg) => (
|
||||
<View key={beleg.buchungId} style={stile.zeile}>
|
||||
<Text style={stile.nummer}>{beleg.nummer}</Text>
|
||||
<Text style={stile.datum}>{formatDatum(beleg.datum)}</Text>
|
||||
<View style={stile.breit}>
|
||||
<Text style={stile.text}>{beleg.beschriftung}</Text>
|
||||
{beleg.verwendungszweck && beleg.gegenstelle && (
|
||||
<Text style={stile.klein}>{beleg.verwendungszweck}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={stile.mittel}>
|
||||
{beleg.belegTitel ? (
|
||||
<Text style={stile.klein}>{beleg.belegTitel}</Text>
|
||||
) : beleg.belegFehltBegruendung ? (
|
||||
<Text style={stile.klein}>
|
||||
ohne Beleg – {beleg.belegFehltBegruendung}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[stile.klein, stile.fett]}>Beleg fehlt</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={stile.betrag}>
|
||||
{beleg.richtung === 'AUSGABE'
|
||||
? formatCent(-beleg.betragCent)
|
||||
: formatCent(beleg.betragCent)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!voll && rechnung.beleg.ohneNachweis > 0 && (
|
||||
<View style={stile.hinweisblock}>
|
||||
<Text style={[stile.hinweistext, stile.fett]}>Belege</Text>
|
||||
<Text style={stile.hinweistext}>
|
||||
Von {rechnung.beleg.gesamt} Buchungen des Berichtszeitraums sind{' '}
|
||||
{rechnung.beleg.mitBeleg} mit einem Beleg hinterlegt, {rechnung.beleg.begruendet}{' '}
|
||||
tragen eine Begründung für den fehlenden Beleg, und{' '}
|
||||
{rechnung.beleg.ohneNachweis} haben beides nicht.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rechnung.hinweise.length > 0 && (
|
||||
<View style={stile.hinweisblock}>
|
||||
<Text style={[stile.hinweistext, stile.fett]}>Anmerkungen zur Aufstellung</Text>
|
||||
{rechnung.hinweise.map((hinweis, i) => (
|
||||
<Text key={i} style={stile.hinweistext}>
|
||||
• {hinweis}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={[stile.erklaerung, { marginTop: 12 }]}>
|
||||
Ich versichere, die Aufstellung nach bestem Wissen und Gewissen und auf Grundlage der
|
||||
vorliegenden Kontoauszüge und Belege erstellt zu haben.
|
||||
</Text>
|
||||
</Berichtsseite>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
|
||||
export async function rechnungslegungPdf(daten: RechnungslegungDaten): Promise<Buffer> {
|
||||
return pdfErzeugen(<RechnungslegungDokument daten={daten} />)
|
||||
}
|
||||
|
||||
export function rechnungslegungDateiname(daten: RechnungslegungDaten): string {
|
||||
const bis = daten.jahr.bis.toISOString().slice(0, 10)
|
||||
const name = daten.betreute.name.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
const art =
|
||||
daten.rechnung.variante === 'UEBERSICHT_1859' ? 'Vermoegensuebersicht' : 'Rechnungslegung'
|
||||
return `${bis}-${art}-Betreuungsjahr-${daten.jahr.nummer}-${name}.pdf`
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import 'server-only'
|
||||
import { createElement as e, type ReactElement, type ReactNode } from 'react'
|
||||
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
|
||||
import { Berichtsseite, type Berichtskopf } from '@/lib/pdf/rahmen'
|
||||
import { paginieren, pdfErzeugen, pdfsZusammenfuehren } from '@/lib/pdf/erzeugen'
|
||||
import { lesen } from '@/lib/speicher'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { berichtDateiname } from '@/lib/validierung/berichte'
|
||||
|
||||
/**
|
||||
* Unterlagenpaket fuer den Sozialhilfetraeger.
|
||||
*
|
||||
* Der Bezirk fordert Unterlagen nach - meist zehn bis zwanzig Positionen,
|
||||
* verteilt ueber mehrere Schreiben. Wer darauf mit einem Stapel loser
|
||||
* Kopien antwortet, bekommt Wochen spaeter die Nachfrage, welche Unterlage
|
||||
* denn nun zu welcher Position gehoere.
|
||||
*
|
||||
* Deshalb entsteht hier EIN PDF: Anschreiben mit laufender Nummerierung,
|
||||
* Inhaltsverzeichnis, dann die Unterlagen in genau dieser Reihenfolge,
|
||||
* durchpaginiert. Auch das, was es nicht gibt, steht drin - mit Begruendung.
|
||||
* Eine nicht beantwortete Position gilt beim Traeger sonst als fehlende
|
||||
* Mitwirkung, und das kostet Leistung.
|
||||
*
|
||||
* Ohne JSX, weil die Datei nach dem Bauplan .ts heisst.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Anschreiben und Inhaltsverzeichnis liegen vor den Anlagen. Beide sind auf
|
||||
* je eine Seite hin gestaltet; wird eines laenger, verschiebt sich die
|
||||
* Angabe im Verzeichnis um denselben Betrag nach hinten. Das nehmen wir in
|
||||
* Kauf - die laufende Nummer bleibt in jedem Fall richtig, und danach
|
||||
* sucht der Sachbearbeiter.
|
||||
*/
|
||||
const VORSPANN_SEITEN = 2
|
||||
|
||||
const stile = StyleSheet.create({
|
||||
absatz: { fontSize: 10, lineHeight: 1.5, marginBottom: 8, textAlign: 'justify' },
|
||||
betreff: { fontSize: 11, fontFamily: 'Helvetica-Bold', marginBottom: 10 },
|
||||
ueberschrift: { fontSize: 12, fontFamily: 'Helvetica-Bold', marginTop: 16, marginBottom: 6 },
|
||||
anschrift: { fontSize: 10, lineHeight: 1.4, marginBottom: 18 },
|
||||
zeile: { flexDirection: 'row', marginBottom: 4 },
|
||||
nummer: { fontSize: 10, width: 28 },
|
||||
bezeichnung: { fontSize: 10, flexGrow: 1, paddingRight: 8 },
|
||||
bemerkung: { fontSize: 9, color: '#555555', width: 150, textAlign: 'right' },
|
||||
fehlend: { fontSize: 10, lineHeight: 1.45, marginBottom: 6, marginLeft: 28 },
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Aufstellung
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type Unterlage = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
erlaeuterung: string | null
|
||||
status: string
|
||||
begruendungNichtVorhanden: string | null
|
||||
reihenfolge: number
|
||||
/** Angehaengte Dokumente in der Reihenfolge, in der sie eingescannt wurden. */
|
||||
dokumente: {
|
||||
id: string
|
||||
titel: string
|
||||
schreibenVom: Date | null
|
||||
objektSchluessel: string | null
|
||||
mimeTyp: string | null
|
||||
seitenzahl: number | null
|
||||
}[]
|
||||
}
|
||||
|
||||
export type Aufstellung = {
|
||||
/** Position im Anschreiben - beginnt bei 1 und zaehlt lueckenlos hoch. */
|
||||
nummer: number
|
||||
unterlage: Unterlage
|
||||
beigefuegt: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Nummeriert die ausgewaehlten Positionen. Reine Funktion, damit die
|
||||
* laufende Nummer im Anschreiben, im Inhaltsverzeichnis und in der
|
||||
* Reihenfolge der angehaengten Seiten garantiert dieselbe ist.
|
||||
*/
|
||||
export function unterlagenAufstellen(unterlagen: Unterlage[]): Aufstellung[] {
|
||||
return [...unterlagen]
|
||||
.sort((a, b) => a.reihenfolge - b.reihenfolge || a.bezeichnung.localeCompare(b.bezeichnung, 'de'))
|
||||
.map((unterlage, i) => ({
|
||||
nummer: i + 1,
|
||||
unterlage,
|
||||
beigefuegt: unterlage.dokumente.some((d) => d.objektSchluessel !== null),
|
||||
}))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Anschreiben und Inhaltsverzeichnis
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type Anschreibendaten = {
|
||||
traeger: string
|
||||
leistungsart: string
|
||||
aktenzeichen: string | null
|
||||
sachbearbeiter: string | null
|
||||
betreuteName: string
|
||||
betreuteGeburtsdatum: Date | null
|
||||
erstelltVon: string
|
||||
erstelltAm: Date
|
||||
/** Freitext, den der Betreuer im Formular ergaenzt hat. */
|
||||
zusatz: string
|
||||
}
|
||||
|
||||
function anschriftBlock(daten: Anschreibendaten) {
|
||||
const zeilen = [daten.traeger]
|
||||
if (daten.sachbearbeiter) zeilen.push(`z. Hd. ${daten.sachbearbeiter}`)
|
||||
return e(
|
||||
View,
|
||||
{ style: stile.anschrift },
|
||||
...zeilen.map((zeileText, i) => e(Text, { key: `an-${i}` }, zeileText)),
|
||||
)
|
||||
}
|
||||
|
||||
function positionsZeile(eintrag: Aufstellung) {
|
||||
const bemerkung = eintrag.beigefuegt
|
||||
? `Anlage ${eintrag.nummer}`
|
||||
: 'nicht vorhanden – siehe Erklärung'
|
||||
return e(
|
||||
View,
|
||||
{ key: `pos-${eintrag.unterlage.id}`, style: stile.zeile, wrap: false },
|
||||
e(Text, { style: stile.nummer }, `${eintrag.nummer}.`),
|
||||
e(Text, { style: stile.bezeichnung }, eintrag.unterlage.bezeichnung),
|
||||
e(Text, { style: stile.bemerkung }, bemerkung),
|
||||
)
|
||||
}
|
||||
|
||||
/** Das Anschreiben als eigenstaendiges react-pdf-Dokument. */
|
||||
export function AnschreibenDokument(props: { kopf: Berichtskopf; daten: Anschreibendaten; aufstellung: Aufstellung[] }) {
|
||||
const { kopf, daten, aufstellung } = props
|
||||
const beigefuegt = aufstellung.filter((a) => a.beigefuegt)
|
||||
const fehlend = aufstellung.filter((a) => !a.beigefuegt)
|
||||
|
||||
const kinder: ReactNode[] = [
|
||||
anschriftBlock(daten),
|
||||
e(
|
||||
Text,
|
||||
{ key: 'betreff', style: stile.betreff },
|
||||
`Betreff: ${daten.leistungsart} für ${daten.betreuteName}` +
|
||||
(daten.aktenzeichen ? ` – Aktenzeichen ${daten.aktenzeichen}` : ''),
|
||||
),
|
||||
e(
|
||||
Text,
|
||||
{ key: 'anrede', style: stile.absatz },
|
||||
daten.sachbearbeiter ? `Sehr geehrte Damen und Herren,` : 'Sehr geehrte Damen und Herren,',
|
||||
),
|
||||
e(
|
||||
Text,
|
||||
{ key: 'einleitung', style: stile.absatz },
|
||||
`als rechtlicher Betreuer von ${daten.betreuteName}` +
|
||||
(daten.betreuteGeburtsdatum ? `, geboren am ${formatDatum(daten.betreuteGeburtsdatum)}` : '') +
|
||||
`, übersende ich Ihnen die von Ihnen angeforderten Unterlagen. ` +
|
||||
`Die Unterlagen sind fortlaufend nummeriert und in dieser Reihenfolge diesem Schreiben ` +
|
||||
`beigefügt; das Inhaltsverzeichnis auf der Folgeseite nennt die zugehörigen Seiten.`,
|
||||
),
|
||||
e(Text, { key: 'ueberschrift-liste', style: stile.ueberschrift }, 'Beigefügte Unterlagen'),
|
||||
]
|
||||
|
||||
if (beigefuegt.length > 0) {
|
||||
kinder.push(e(View, { key: 'liste' }, ...beigefuegt.map(positionsZeile)))
|
||||
} else {
|
||||
kinder.push(
|
||||
e(
|
||||
Text,
|
||||
{ key: 'liste-leer', style: stile.absatz },
|
||||
'Diesem Schreiben liegen keine Unterlagen bei; zu den angeforderten Positionen finden Sie ' +
|
||||
'unten die jeweilige Erklärung.',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (fehlend.length > 0) {
|
||||
kinder.push(
|
||||
e(
|
||||
Text,
|
||||
{ key: 'ueberschrift-fehlend', style: stile.ueberschrift },
|
||||
'Nicht vorhandene Unterlagen',
|
||||
),
|
||||
e(
|
||||
Text,
|
||||
{ key: 'fehlend-einleitung', style: stile.absatz },
|
||||
'Zu den folgenden Positionen kann ich keine Unterlage vorlegen. Ich erkläre dazu:',
|
||||
),
|
||||
...fehlend.map((eintrag) =>
|
||||
e(
|
||||
Text,
|
||||
{ key: `fehlend-${eintrag.unterlage.id}`, style: stile.fehlend },
|
||||
`${eintrag.nummer}. ${eintrag.unterlage.bezeichnung} — ` +
|
||||
(eintrag.unterlage.begruendungNichtVorhanden?.trim() ||
|
||||
'Diese Unterlage liegt mir nicht vor. Ich bemühe mich weiter darum und reiche sie nach, sobald sie mir vorliegt.'),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (daten.zusatz.trim() !== '') {
|
||||
kinder.push(
|
||||
e(Text, { key: 'zusatz-ueberschrift', style: stile.ueberschrift }, 'Ergänzende Hinweise'),
|
||||
e(Text, { key: 'zusatz', style: stile.absatz }, daten.zusatz.trim()),
|
||||
)
|
||||
}
|
||||
|
||||
kinder.push(
|
||||
e(
|
||||
Text,
|
||||
{ key: 'schluss', style: stile.absatz },
|
||||
'Für Rückfragen stehe ich Ihnen gerne zur Verfügung. Ich bitte um eine kurze ' +
|
||||
'Eingangsbestätigung.',
|
||||
),
|
||||
e(Text, { key: 'gruss', style: stile.absatz }, 'Mit freundlichen Grüßen'),
|
||||
)
|
||||
|
||||
return e(
|
||||
Document,
|
||||
{ title: kopf.titel, author: daten.erstelltVon, language: 'de' },
|
||||
e(Berichtsseite, { kopf, mitUnterschrift: true }, ...kinder),
|
||||
)
|
||||
}
|
||||
|
||||
/** Das Inhaltsverzeichnis mit den Seitenzahlen der Anlagen. */
|
||||
export function InhaltsverzeichnisDokument(props: {
|
||||
kopf: Berichtskopf
|
||||
aufstellung: Aufstellung[]
|
||||
/** Erste Seite der jeweiligen Anlage im zusammengefuehrten PDF. */
|
||||
startseiten: Map<string, number>
|
||||
}) {
|
||||
const { kopf, aufstellung, startseiten } = props
|
||||
|
||||
return e(
|
||||
Document,
|
||||
{ title: `${kopf.titel} – Inhaltsverzeichnis`, author: kopf.erstelltVon, language: 'de' },
|
||||
e(
|
||||
Berichtsseite,
|
||||
{ kopf: { ...kopf, titel: `${kopf.titel} – Inhaltsverzeichnis` }, mitUnterschrift: false },
|
||||
e(
|
||||
Text,
|
||||
{ key: 'einleitung', style: stile.absatz },
|
||||
'Die Seitenzahlen beziehen sich auf die durchlaufende Nummerierung am Fuß dieses ' +
|
||||
'Dokuments.',
|
||||
),
|
||||
...aufstellung.map((eintrag) => {
|
||||
const seite = startseiten.get(eintrag.unterlage.id)
|
||||
const bemerkung = eintrag.beigefuegt
|
||||
? seite !== undefined
|
||||
? `ab Seite ${seite}`
|
||||
: 'beigefügt'
|
||||
: 'nicht vorhanden'
|
||||
return e(
|
||||
View,
|
||||
{ key: `inh-${eintrag.unterlage.id}`, style: stile.zeile, wrap: false },
|
||||
e(Text, { style: stile.nummer }, `${eintrag.nummer}.`),
|
||||
e(Text, { style: stile.bezeichnung }, eintrag.unterlage.bezeichnung),
|
||||
e(Text, { style: stile.bemerkung }, bemerkung),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Zusammenbau
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type UnterlagenpaketErgebnis = {
|
||||
pdf: Buffer
|
||||
dateiname: string
|
||||
aufstellung: Aufstellung[]
|
||||
/** Unterlagen, deren Datei nicht gelesen werden konnte. */
|
||||
uebersprungen: { bezeichnung: string; grund: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt das komplette Paket. Der Aufrufer hat den Zugriff auf die
|
||||
* betreute Person bereits geprueft.
|
||||
*/
|
||||
export async function unterlagenpaketErzeugen(optionen: {
|
||||
betreuteId: string
|
||||
antragId: string
|
||||
nachforderungIds: string[]
|
||||
anschreibenZusatz: string
|
||||
erstelltVon: string
|
||||
erstelltAm?: Date
|
||||
}): Promise<UnterlagenpaketErgebnis> {
|
||||
const erstelltAm = optionen.erstelltAm ?? new Date()
|
||||
|
||||
const [betreute, antrag, nachforderungen] = await Promise.all([
|
||||
db.betreute.findFirstOrThrow({
|
||||
where: { id: optionen.betreuteId, ...nichtGeloescht },
|
||||
select: { vorname: true, nachname: true, anrede: true, geburtsdatum: true },
|
||||
}),
|
||||
db.sozialhilfeAntrag.findFirstOrThrow({
|
||||
where: { id: optionen.antragId, betreuteId: optionen.betreuteId, ...nichtGeloescht },
|
||||
select: {
|
||||
traeger: true,
|
||||
leistungsart: true,
|
||||
aktenzeichen: true,
|
||||
sachbearbeiter: true,
|
||||
},
|
||||
}),
|
||||
db.nachforderung.findMany({
|
||||
where: {
|
||||
id: { in: optionen.nachforderungIds },
|
||||
antragId: optionen.antragId,
|
||||
antrag: { betreuteId: optionen.betreuteId },
|
||||
...nichtGeloescht,
|
||||
},
|
||||
include: {
|
||||
verknuepfungen: {
|
||||
where: { betreuteId: optionen.betreuteId },
|
||||
include: {
|
||||
dokument: {
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
schreibenVom: true,
|
||||
objektSchluessel: true,
|
||||
mimeTyp: true,
|
||||
seitenzahl: true,
|
||||
geloeschtAm: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const betreuteName = [betreute.anrede, betreute.vorname, betreute.nachname]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const unterlagen: Unterlage[] = nachforderungen.map((n) => ({
|
||||
id: n.id,
|
||||
bezeichnung: n.bezeichnung,
|
||||
erlaeuterung: n.erlaeuterung,
|
||||
status: n.status,
|
||||
begruendungNichtVorhanden: n.begruendungNichtVorhanden,
|
||||
reihenfolge: n.reihenfolge,
|
||||
dokumente: n.verknuepfungen
|
||||
.map((v) => v.dokument)
|
||||
.filter((d): d is NonNullable<typeof d> => d !== null && d.geloeschtAm === null)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
titel: d.titel,
|
||||
schreibenVom: d.schreibenVom,
|
||||
objektSchluessel: d.objektSchluessel,
|
||||
mimeTyp: d.mimeTyp,
|
||||
seitenzahl: d.seitenzahl,
|
||||
})),
|
||||
}))
|
||||
|
||||
const aufstellung = unterlagenAufstellen(unterlagen)
|
||||
|
||||
const kopf: Berichtskopf = {
|
||||
titel: 'Unterlagen zur Hilfe zur Pflege',
|
||||
betreute: { name: betreuteName, geburtsdatum: betreute.geburtsdatum },
|
||||
behoerde: antrag.traeger,
|
||||
aktenzeichen: antrag.aktenzeichen ?? undefined,
|
||||
rechtsgrundlage: '§ 60 SGB I (Mitwirkungspflicht)',
|
||||
erstelltAm,
|
||||
erstelltVon: optionen.erstelltVon,
|
||||
}
|
||||
|
||||
const daten: Anschreibendaten = {
|
||||
traeger: antrag.traeger,
|
||||
leistungsart: antrag.leistungsart,
|
||||
aktenzeichen: antrag.aktenzeichen,
|
||||
sachbearbeiter: antrag.sachbearbeiter,
|
||||
betreuteName,
|
||||
betreuteGeburtsdatum: betreute.geburtsdatum,
|
||||
erstelltVon: optionen.erstelltVon,
|
||||
erstelltAm,
|
||||
zusatz: optionen.anschreibenZusatz,
|
||||
}
|
||||
|
||||
// 1. Anlagen einlesen und dabei merken, auf welcher Seite jede beginnt.
|
||||
const uebersprungen: { bezeichnung: string; grund: string }[] = []
|
||||
const anlagen: Buffer[] = []
|
||||
const startseiten = new Map<string, number>()
|
||||
|
||||
// Anschreiben und Inhaltsverzeichnis stehen vorne; ihre Seitenzahl steht
|
||||
// erst nach dem Rendern fest, deshalb zunaechst als Versatz gefuehrt.
|
||||
let seitenversatz = 0
|
||||
|
||||
for (const eintrag of aufstellung) {
|
||||
if (!eintrag.beigefuegt) continue
|
||||
for (const dokument of eintrag.unterlage.dokumente) {
|
||||
if (!dokument.objektSchluessel) continue
|
||||
try {
|
||||
const inhalt = await lesen(dokument.objektSchluessel)
|
||||
if (!startseiten.has(eintrag.unterlage.id)) {
|
||||
startseiten.set(eintrag.unterlage.id, seitenversatz + 1)
|
||||
}
|
||||
anlagen.push(inhalt)
|
||||
seitenversatz += dokument.seitenzahl ?? 1
|
||||
} catch (fehler) {
|
||||
uebersprungen.push({
|
||||
bezeichnung: `${eintrag.unterlage.bezeichnung} – ${dokument.titel}`,
|
||||
grund: (fehler as Error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!startseiten.has(eintrag.unterlage.id)) {
|
||||
// Alle Dateien dieser Position waren unlesbar.
|
||||
eintrag.beigefuegt = false
|
||||
}
|
||||
}
|
||||
|
||||
const anschreiben = await pdfErzeugen(
|
||||
AnschreibenDokument({ kopf, daten, aufstellung }) as ReactElement,
|
||||
)
|
||||
const verzeichnisSeiten = new Map(
|
||||
[...startseiten].map(([id, seite]) => [id, seite + VORSPANN_SEITEN]),
|
||||
)
|
||||
const inhaltsverzeichnis = await pdfErzeugen(
|
||||
InhaltsverzeichnisDokument({
|
||||
kopf,
|
||||
aufstellung,
|
||||
startseiten: verzeichnisSeiten,
|
||||
}) as ReactElement,
|
||||
)
|
||||
|
||||
const zusammengefuehrt = await pdfsZusammenfuehren([anschreiben, inhaltsverzeichnis, ...anlagen])
|
||||
const pdf = await paginieren(
|
||||
zusammengefuehrt,
|
||||
`${betreuteName}${antrag.aktenzeichen ? ` · ${antrag.aktenzeichen}` : ''} · ` +
|
||||
`Unterlagen vom ${formatDatum(erstelltAm)}`,
|
||||
)
|
||||
|
||||
return {
|
||||
pdf,
|
||||
dateiname: berichtDateiname('unterlagen-bezirk', betreute.nachname, erstelltAm, 'pdf'),
|
||||
aufstellung,
|
||||
uebersprungen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import 'server-only'
|
||||
import * as React from 'react'
|
||||
import { Document, StyleSheet, Text, View } from '@react-pdf/renderer'
|
||||
import { Berichtsseite } from '@/lib/pdf/rahmen'
|
||||
import { pdfErzeugen } from '@/lib/pdf/erzeugen'
|
||||
import { formatCent, formatDatum } from '@/lib/utils'
|
||||
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
|
||||
import type { VermoegensverzeichnisDaten } from '@/lib/berichte/gemeinsam'
|
||||
|
||||
/**
|
||||
* Vermoegensverzeichnis nach § 1835 BGB.
|
||||
*
|
||||
* Binnen drei Monaten nach der Bestellung beim Betreuungsgericht
|
||||
* einzureichen. Es beantwortet eine einzige Frage vollstaendig: Was
|
||||
* gehoert der betreuten Person am Stichtag, und was schuldet sie?
|
||||
*
|
||||
* Drei Dinge machen dieses Verzeichnis brauchbar - und alle drei sind
|
||||
* hier eingebaut:
|
||||
*
|
||||
* 1. Jede Zahl traegt ihre Herkunft. Neben jedem Wert steht, woher er
|
||||
* stammt und von wann er ist. Ein Betrag ohne Quelle ist beim Gericht
|
||||
* wertlos.
|
||||
* 2. Luecken werden benannt, nicht mit Nullen gefuellt. Ein Posten ohne
|
||||
* Bewertung steht ausdruecklich als "noch nicht bewertet" drin.
|
||||
* 3. Neben dem Bestand stehen die laufenden Einnahmen und Ausgaben.
|
||||
* Danach fragt das Gericht als Naechstes, und danach fragt der Bezirk
|
||||
* im Verfahren zur Hilfe zur Pflege.
|
||||
*/
|
||||
|
||||
const stile = StyleSheet.create({
|
||||
abschnitt: { marginTop: 14 },
|
||||
ueberschrift: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginBottom: 5,
|
||||
paddingBottom: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#333333',
|
||||
},
|
||||
untertitel: {
|
||||
fontSize: 9.5,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginTop: 8,
|
||||
marginBottom: 3,
|
||||
},
|
||||
zeile: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 3,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: '#dddddd',
|
||||
},
|
||||
kopfzeile: {
|
||||
flexDirection: 'row',
|
||||
paddingBottom: 2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#999999',
|
||||
},
|
||||
kopftext: { fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#444444' },
|
||||
text: { fontSize: 9 },
|
||||
klein: { fontSize: 7.5, color: '#555555' },
|
||||
breit: { flex: 1, paddingRight: 6 },
|
||||
mittel: { width: 120, paddingRight: 6 },
|
||||
schmal: { width: 62, paddingRight: 6 },
|
||||
betrag: { width: 82, textAlign: 'right', fontSize: 9 },
|
||||
summenzeile: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: 4,
|
||||
marginTop: 2,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#333333',
|
||||
},
|
||||
summentext: { flex: 1, fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
|
||||
summenbetrag: { width: 82, textAlign: 'right', fontSize: 9.5, fontFamily: 'Helvetica-Bold' },
|
||||
hinweisblock: {
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#b45309',
|
||||
backgroundColor: '#fffbeb',
|
||||
},
|
||||
hinweistext: { fontSize: 8, marginBottom: 3 },
|
||||
erklaerung: { fontSize: 8, color: '#555555', marginBottom: 6 },
|
||||
})
|
||||
|
||||
function Summenzeile({ text, betragCent }: { text: string; betragCent: bigint }) {
|
||||
return (
|
||||
<View style={stile.summenzeile}>
|
||||
<Text style={stile.summentext}>{text}</Text>
|
||||
<Text style={stile.summenbetrag}>{formatCent(betragCent)}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Tabellenkopf({ spalten }: { spalten: [string, string, string, string] }) {
|
||||
return (
|
||||
<View style={stile.kopfzeile}>
|
||||
<Text style={[stile.kopftext, stile.breit]}>{spalten[0]}</Text>
|
||||
<Text style={[stile.kopftext, stile.mittel]}>{spalten[1]}</Text>
|
||||
<Text style={[stile.kopftext, stile.schmal]}>{spalten[2]}</Text>
|
||||
<Text style={[stile.kopftext, stile.betrag]}>{spalten[3]}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function VermoegensverzeichnisDokument({
|
||||
daten,
|
||||
}: {
|
||||
daten: VermoegensverzeichnisDaten
|
||||
}): React.ReactElement {
|
||||
const kontenOhneBetrag = daten.kontostaende.filter((k) => k.betragCent === null)
|
||||
|
||||
return (
|
||||
<Document
|
||||
title={`Vermögensverzeichnis ${daten.betreute.name}`}
|
||||
author={daten.kopf.erstelltVon}
|
||||
subject={`Vermögensverzeichnis nach § 1835 BGB zum ${formatDatum(daten.stichtag)}`}
|
||||
>
|
||||
<Berichtsseite kopf={daten.kopf} mitUnterschrift>
|
||||
<Text style={stile.erklaerung}>
|
||||
Verzeichnis des Vermögens der betreuten Person zum Stichtag {formatDatum(daten.stichtag)},
|
||||
aufgestellt nach § 1835 BGB. Jeder Wert ist mit seiner Herkunft und dem Tag seiner
|
||||
Bewertung angegeben. Beträge, für die zum Stichtag keine belastbare Bewertung vorliegt,
|
||||
sind am Ende des Verzeichnisses gesondert aufgeführt und nicht in die Summen eingerechnet.
|
||||
</Text>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>A. Aktiva</Text>
|
||||
|
||||
<Text style={stile.untertitel}>I. Konten und Guthaben</Text>
|
||||
{daten.kontostaende.length === 0 ? (
|
||||
<Text style={stile.text}>Keine Konten erfasst.</Text>
|
||||
) : (
|
||||
<>
|
||||
<Tabellenkopf spalten={['Konto', 'Bank / IBAN', 'Stand vom', 'Betrag']} />
|
||||
{daten.kontostaende.map((konto) => (
|
||||
<View key={konto.kontoId} style={stile.zeile}>
|
||||
<View style={stile.breit}>
|
||||
<Text style={stile.text}>{konto.bezeichnung}</Text>
|
||||
<Text style={stile.klein}>{konto.artBeschriftung}</Text>
|
||||
</View>
|
||||
<View style={stile.mittel}>
|
||||
<Text style={stile.klein}>{konto.bank ?? '–'}</Text>
|
||||
{konto.ibanMaskiert && <Text style={stile.klein}>{konto.ibanMaskiert}</Text>}
|
||||
</View>
|
||||
<View style={stile.schmal}>
|
||||
<Text style={stile.klein}>{formatDatum(konto.stichtag)}</Text>
|
||||
{konto.quelle && <Text style={stile.klein}>{konto.quelle}</Text>}
|
||||
</View>
|
||||
<Text style={stile.betrag}>
|
||||
{konto.betragCent === null ? 'nicht erfasst' : formatCent(konto.betragCent)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
<Summenzeile text="Summe Konten und Guthaben" betragCent={daten.summeKontenCent} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{daten.vermoegen.aktivaGruppen.map((gruppe, i) => (
|
||||
<View key={gruppe.art} wrap={false}>
|
||||
<Text style={stile.untertitel}>
|
||||
{romanisch(i + 2)}. {VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}
|
||||
</Text>
|
||||
<Tabellenkopf spalten={['Posten', 'Wertquelle / Nachweis', 'Bewertet', 'Wert']} />
|
||||
{gruppe.posten.map((posten) => (
|
||||
<View key={posten.id} style={stile.zeile}>
|
||||
<View style={stile.breit}>
|
||||
<Text style={stile.text}>{posten.bezeichnung}</Text>
|
||||
</View>
|
||||
<View style={stile.mittel}>
|
||||
<Text style={stile.klein}>{posten.wertquelle}</Text>
|
||||
{posten.nachweisTitel && (
|
||||
<Text style={stile.klein}>Beleg: {posten.nachweisTitel}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[stile.klein, stile.schmal]}>{formatDatum(posten.bewertetAm)}</Text>
|
||||
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
<Summenzeile
|
||||
text={`Summe ${VERMOEGENS_ART_BESCHRIFTUNG[gruppe.art]}`}
|
||||
betragCent={gruppe.summeCent}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Summenzeile text="Summe Aktiva" betragCent={daten.summeAktivaCent} />
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>B. Passiva (Verbindlichkeiten)</Text>
|
||||
{daten.vermoegen.passiva.length === 0 ? (
|
||||
<Text style={stile.text}>Keine Verbindlichkeiten erfasst.</Text>
|
||||
) : (
|
||||
<>
|
||||
<Tabellenkopf spalten={['Verbindlichkeit', 'Quelle / Nachweis', 'Bewertet', 'Betrag']} />
|
||||
{daten.vermoegen.passiva.map((posten) => (
|
||||
<View key={posten.id} style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
|
||||
<View style={stile.mittel}>
|
||||
<Text style={stile.klein}>{posten.wertquelle}</Text>
|
||||
{posten.nachweisTitel && (
|
||||
<Text style={stile.klein}>Beleg: {posten.nachweisTitel}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[stile.klein, stile.schmal]}>{formatDatum(posten.bewertetAm)}</Text>
|
||||
<Text style={stile.betrag}>{formatCent(posten.wertCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
<Summenzeile text="Summe Passiva" betragCent={daten.summePassivaCent} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>C. Reinvermögen zum Stichtag</Text>
|
||||
<View style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>Aktiva</Text>
|
||||
<Text style={stile.betrag}>{formatCent(daten.summeAktivaCent)}</Text>
|
||||
</View>
|
||||
<View style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>abzüglich Passiva</Text>
|
||||
<Text style={stile.betrag}>{formatCent(-daten.summePassivaCent)}</Text>
|
||||
</View>
|
||||
<Summenzeile text="Reinvermögen" betragCent={daten.reinvermoegenCent} />
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
<View style={stile.abschnitt} break>
|
||||
<Text style={stile.ueberschrift}>
|
||||
D. Laufende Einnahmen und Ausgaben ({daten.laufend.monate} Monate bis{' '}
|
||||
{formatDatum(daten.laufend.bis)})
|
||||
</Text>
|
||||
<Text style={stile.erklaerung}>
|
||||
Grundlage sind die im Kassenbuch erfassten Buchungen des genannten Zeitraums, auf den
|
||||
Monat heruntergerechnet. Einmalige vermögenswirksame Vorgänge – etwa ein Verkaufserlös
|
||||
oder ein Rückkaufswert – sind hier bewusst nicht enthalten; sie sind keine laufenden
|
||||
Einnahmen und stehen im Bestandsteil dieses Verzeichnisses.
|
||||
</Text>
|
||||
|
||||
<Text style={stile.untertitel}>Einnahmen</Text>
|
||||
{daten.laufend.einnahmen.length === 0 ? (
|
||||
<Text style={stile.text}>Keine laufenden Einnahmen erfasst.</Text>
|
||||
) : (
|
||||
<>
|
||||
<Tabellenkopf spalten={['Art der Einnahme', '', 'Vorgänge', 'je Monat']} />
|
||||
{daten.laufend.einnahmen.map((posten) => (
|
||||
<View key={posten.kategorie} style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>{posten.beschriftung}</Text>
|
||||
<Text style={[stile.klein, stile.mittel]}>
|
||||
Zeitraum gesamt: {formatCent(posten.summeCent)}
|
||||
</Text>
|
||||
<Text style={[stile.klein, stile.schmal]}>{posten.anzahl}</Text>
|
||||
<Text style={stile.betrag}>{formatCent(posten.monatlichCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
<Summenzeile
|
||||
text="Summe laufende Einnahmen je Monat"
|
||||
betragCent={daten.laufend.monatlichEinnahmenCent}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={stile.untertitel}>Ausgaben</Text>
|
||||
{daten.laufend.ausgaben.length === 0 ? (
|
||||
<Text style={stile.text}>Keine laufenden Ausgaben erfasst.</Text>
|
||||
) : (
|
||||
<>
|
||||
<Tabellenkopf spalten={['Art der Ausgabe', '', 'Vorgänge', 'je Monat']} />
|
||||
{daten.laufend.ausgaben.map((posten) => (
|
||||
<View key={posten.kategorie} style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>{posten.beschriftung}</Text>
|
||||
<Text style={[stile.klein, stile.mittel]}>
|
||||
Zeitraum gesamt: {formatCent(posten.summeCent)}
|
||||
</Text>
|
||||
<Text style={[stile.klein, stile.schmal]}>{posten.anzahl}</Text>
|
||||
<Text style={stile.betrag}>{formatCent(posten.monatlichCent)}</Text>
|
||||
</View>
|
||||
))}
|
||||
<Summenzeile
|
||||
text="Summe laufende Ausgaben je Monat"
|
||||
betragCent={daten.laufend.monatlichAusgabenCent}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Summenzeile
|
||||
text="Rechnerischer Überschuss / Fehlbetrag je Monat"
|
||||
betragCent={
|
||||
daten.laufend.monatlichEinnahmenCent - daten.laufend.monatlichAusgabenCent
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ------------------------------------------------------------- */}
|
||||
{(daten.vermoegen.ohneWert.length > 0 || kontenOhneBetrag.length > 0) && (
|
||||
<View style={stile.abschnitt}>
|
||||
<Text style={stile.ueberschrift}>E. Noch nicht bewertete Posten</Text>
|
||||
<Text style={stile.erklaerung}>
|
||||
Diese Posten sind vorhanden, konnten zum Stichtag aber nicht belastbar bewertet
|
||||
werden. Sie sind in keiner Summe dieses Verzeichnisses enthalten.
|
||||
</Text>
|
||||
{kontenOhneBetrag.map((konto) => (
|
||||
<View key={konto.kontoId} style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>{konto.bezeichnung}</Text>
|
||||
<Text style={[stile.klein, stile.mittel]}>
|
||||
Kein Saldostand zum Stichtag erfasst.
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{daten.vermoegen.ohneWert.map((posten) => (
|
||||
<View key={posten.id} style={stile.zeile}>
|
||||
<Text style={[stile.text, stile.breit]}>{posten.bezeichnung}</Text>
|
||||
<Text style={[stile.klein, stile.mittel]}>
|
||||
{VERMOEGENS_ART_BESCHRIFTUNG[posten.art]} – keine Bewertung zum Stichtag.
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{daten.hinweise.length > 0 && (
|
||||
<View style={stile.hinweisblock}>
|
||||
<Text style={[stile.hinweistext, { fontFamily: 'Helvetica-Bold' }]}>
|
||||
Anmerkungen zur Aufstellung
|
||||
</Text>
|
||||
{daten.hinweise.map((hinweis, i) => (
|
||||
<Text key={i} style={stile.hinweistext}>
|
||||
• {hinweis}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={[stile.erklaerung, { marginTop: 12 }]}>
|
||||
Ich versichere, das Verzeichnis nach bestem Wissen und Gewissen aufgestellt zu haben.
|
||||
Die angegebenen Werte beruhen auf den jeweils genannten Quellen; die zugehörigen
|
||||
Nachweise können auf Anforderung vorgelegt werden.
|
||||
</Text>
|
||||
</Berichtsseite>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
|
||||
/** I, II, III … fuer die Gliederung der Aktiva. */
|
||||
function romanisch(zahl: number): string {
|
||||
const zeichen: [number, string][] = [
|
||||
[10, 'X'],
|
||||
[9, 'IX'],
|
||||
[5, 'V'],
|
||||
[4, 'IV'],
|
||||
[1, 'I'],
|
||||
]
|
||||
let rest = zahl
|
||||
let ergebnis = ''
|
||||
for (const [wert, text] of zeichen) {
|
||||
while (rest >= wert) {
|
||||
ergebnis += text
|
||||
rest -= wert
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
export async function vermoegensverzeichnisPdf(daten: VermoegensverzeichnisDaten): Promise<Buffer> {
|
||||
return pdfErzeugen(<VermoegensverzeichnisDokument daten={daten} />)
|
||||
}
|
||||
|
||||
/** Dateiname fuer den Download - Datum voran, damit die Ablage sortiert. */
|
||||
export function vermoegensverzeichnisDateiname(daten: VermoegensverzeichnisDaten): string {
|
||||
const stichtag = daten.stichtag.toISOString().slice(0, 10)
|
||||
const name = daten.betreute.name.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
return `${stichtag}-Vermoegensverzeichnis-${name}.pdf`
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import 'server-only'
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Readable } from 'node:stream'
|
||||
import archiver from 'archiver'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { lesen } from '@/lib/speicher'
|
||||
import { oeffnen, VERSCHLUESSELTE_FELDER, type VerschluesseltesModell } from '@/lib/crypto/feld'
|
||||
import { formatDatum, formatDatumZeit } from '@/lib/utils'
|
||||
import {
|
||||
berichtDateiname,
|
||||
exportDokumentPfad,
|
||||
pfadErfordern,
|
||||
sichererDateiname,
|
||||
} from '@/lib/validierung/berichte'
|
||||
|
||||
/**
|
||||
* Vollexport - alles, was ueber eine betreute Person gespeichert ist, in
|
||||
* einem ZIP.
|
||||
*
|
||||
* Das ist die wichtigste Zusage dieser Anwendung an ihren Nutzer: Deine
|
||||
* Daten gehoeren dir, und du kommst jederzeit ohne mich an sie heran. Der
|
||||
* Export enthaelt die Originaldateien, die Datenbankinhalte im Klartext als
|
||||
* JSON, die erzeugten Berichte, eine Anleitung und Pruefsummen. Wer die
|
||||
* Anwendung morgen abschaltet, kann mit diesem Archiv weiterarbeiten.
|
||||
*
|
||||
* Erzeugt wird als Strom. Ein Bestand aus mehreren hundert eingescannten
|
||||
* Bescheiden waere als Buffer im Speicher ein Problem; hier wandert jede
|
||||
* Datei einzeln durch: lesen, Pruefsumme bilden, ins Archiv schieben,
|
||||
* vergessen. Deshalb wird nach jedem Eintrag auf das Ereignis "entry"
|
||||
* gewartet - das ist der Gegendruck, ohne den der Archivierer schneller
|
||||
* annimmt, als der Browser abnimmt.
|
||||
*
|
||||
* Der Export ist eine sensible Aktion: frische Anmeldung, Rolle BETREUER
|
||||
* oder ADMIN, Audit-Eintrag EXPORTIERT. Das prueft die Server Action, nicht
|
||||
* diese Datei.
|
||||
*/
|
||||
|
||||
export type VollexportOptionen = {
|
||||
betreuteId: string
|
||||
mitDokumenten: boolean
|
||||
mitAuditLog: boolean
|
||||
mitBerichten: boolean
|
||||
/** Name des Benutzers, der exportiert - steht in der LIESMICH.txt. */
|
||||
erstelltVon: string
|
||||
erstelltAm?: Date
|
||||
}
|
||||
|
||||
export type VollexportErgebnis = {
|
||||
dateiname: string
|
||||
/** Node-Lesestrom mit dem ZIP. Der Aufrufer reicht ihn an die Antwort weiter. */
|
||||
strom: Readable
|
||||
}
|
||||
|
||||
type Archiv = ReturnType<typeof archiver>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Anhaengen mit Gegendruck und Pruefsumme
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function sha256(inhalt: Buffer | string): string {
|
||||
return createHash('sha256').update(inhalt).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Haengt einen Eintrag an und wartet, bis er geschrieben ist. Ohne dieses
|
||||
* Warten staut sich der gesamte Bestand im Arbeitsspeicher, sobald die
|
||||
* Gegenstelle langsamer ist als die Festplatte.
|
||||
*/
|
||||
function anhaengen(archiv: Archiv, pfad: string, inhalt: Buffer | string): Promise<void> {
|
||||
pfadErfordern(pfad)
|
||||
return new Promise<void>((erfuellen, ablehnen) => {
|
||||
const aufraeumen = () => {
|
||||
archiv.off('entry', beiEintrag)
|
||||
archiv.off('error', beiFehler)
|
||||
}
|
||||
const beiEintrag = (eintrag: { name?: string }) => {
|
||||
if (eintrag?.name !== pfad) return
|
||||
aufraeumen()
|
||||
erfuellen()
|
||||
}
|
||||
const beiFehler = (fehler: Error) => {
|
||||
aufraeumen()
|
||||
ablehnen(fehler)
|
||||
}
|
||||
archiv.on('entry', beiEintrag)
|
||||
archiv.on('error', beiFehler)
|
||||
archiv.append(inhalt, { name: pfad })
|
||||
})
|
||||
}
|
||||
|
||||
/** JSON-fest machen: BigInt wird Zeichenkette, Date wird ISO-Zeitstempel. */
|
||||
export function alsJson(wert: unknown): string {
|
||||
return JSON.stringify(
|
||||
wert,
|
||||
(_schluessel, inhalt) => (typeof inhalt === 'bigint' ? inhalt.toString() : inhalt),
|
||||
2,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt die Umschlagfelder und legt stattdessen den Klartext daneben.
|
||||
* Im Export soll lesbar sein, was in der Anwendung lesbar ist - ein
|
||||
* verschluesselter String nuetzt niemandem, der das Archiv oeffnet.
|
||||
*/
|
||||
export function entschluesseln<T extends Record<string, unknown>>(
|
||||
modell: VerschluesseltesModell | null,
|
||||
datensaetze: T[],
|
||||
): Record<string, unknown>[] {
|
||||
if (!modell) return datensaetze as Record<string, unknown>[]
|
||||
const felder = VERSCHLUESSELTE_FELDER[modell] as readonly string[]
|
||||
|
||||
return datensaetze.map((datensatz) => {
|
||||
const geoeffnet = { ...oeffnen(modell, datensatz) } as Record<string, unknown>
|
||||
for (const feld of felder) delete geoeffnet[feld]
|
||||
return geoeffnet
|
||||
})
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tabellen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type Tabelle = { name: string; modell: VerschluesseltesModell | null; laden: () => Promise<unknown[]> }
|
||||
|
||||
/**
|
||||
* Alle Tabellen, die zu einer betreuten Person gehoeren. Jede Abfrage ist
|
||||
* auf die betreuteId eingegrenzt - direkt oder ueber die Beziehung, an der
|
||||
* sie haengt. Der Papierkorb wird mit ausgegeben: Was geloescht ist, bleibt
|
||||
* dreissig Tage lang wiederherstellbar, und der Export soll die Lage
|
||||
* abbilden, nicht schoenen. Das Feld "geloeschtAm" sagt, woran man ist.
|
||||
*/
|
||||
function tabellen(betreuteId: string, optionen: VollexportOptionen): Tabelle[] {
|
||||
const liste: Tabelle[] = [
|
||||
{
|
||||
name: 'betreute',
|
||||
modell: 'Betreute',
|
||||
laden: () => db.betreute.findMany({ where: { id: betreuteId } }),
|
||||
},
|
||||
{
|
||||
name: 'betreuungszugang',
|
||||
modell: null,
|
||||
laden: () =>
|
||||
db.betreuungsZugang.findMany({
|
||||
where: { betreuteId },
|
||||
include: { user: { select: { name: true, email: true, rolle: true } } },
|
||||
}),
|
||||
},
|
||||
{ name: 'vorgang', modell: 'Vorgang', laden: () => db.vorgang.findMany({ where: { betreuteId } }) },
|
||||
{
|
||||
name: 'aufgabe',
|
||||
modell: null,
|
||||
laden: () => db.aufgabe.findMany({ where: { vorgang: { betreuteId } } }),
|
||||
},
|
||||
{
|
||||
name: 'genehmigung',
|
||||
modell: 'Genehmigung',
|
||||
laden: () => db.genehmigung.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{ name: 'frist', modell: null, laden: () => db.frist.findMany({ where: { betreuteId } }) },
|
||||
{
|
||||
name: 'dokument',
|
||||
modell: 'Dokument',
|
||||
laden: () =>
|
||||
db.dokument.findMany({
|
||||
where: { betreuteId },
|
||||
// Der Suchvektor ist ein Datenbankinterna und nicht exportierbar.
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
art: true,
|
||||
richtung: true,
|
||||
schreibenVom: true,
|
||||
eingangAm: true,
|
||||
versandAm: true,
|
||||
absender: true,
|
||||
empfaenger: true,
|
||||
aktenzeichen: true,
|
||||
objektSchluessel: true,
|
||||
dateiname: true,
|
||||
mimeTyp: true,
|
||||
groesseBytes: true,
|
||||
pruefsumme: true,
|
||||
seitenzahl: true,
|
||||
ocrText: true,
|
||||
ocrStatus: true,
|
||||
posteingangStatus: true,
|
||||
ablageortPapier: true,
|
||||
wiedervorlageAm: true,
|
||||
ablaufAm: true,
|
||||
notizenVerschluesselt: true,
|
||||
erstelltAm: true,
|
||||
aktualisiertAm: true,
|
||||
geloeschtAm: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'dokument_verknuepfung',
|
||||
modell: null,
|
||||
laden: () => db.dokumentVerknuepfung.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{ name: 'tag', modell: null, laden: () => db.tag.findMany({ where: { betreuteId } }) },
|
||||
{ name: 'konto', modell: 'Konto', laden: () => db.konto.findMany({ where: { betreuteId } }) },
|
||||
{
|
||||
name: 'saldostand',
|
||||
modell: null,
|
||||
laden: () => db.saldostand.findMany({ where: { konto: { betreuteId } } }),
|
||||
},
|
||||
{ name: 'buchung', modell: 'Buchung', laden: () => db.buchung.findMany({ where: { betreuteId } }) },
|
||||
{
|
||||
name: 'vermoegensposten',
|
||||
modell: 'Vermoegensposten',
|
||||
laden: () => db.vermoegensposten.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{
|
||||
name: 'wertstand',
|
||||
modell: null,
|
||||
laden: () => db.wertstand.findMany({ where: { vermoegensposten: { betreuteId } } }),
|
||||
},
|
||||
{
|
||||
name: 'immobilie',
|
||||
modell: 'Immobilie',
|
||||
laden: () => db.immobilie.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{
|
||||
name: 'belastung',
|
||||
modell: null,
|
||||
laden: () => db.belastung.findMany({ where: { immobilie: { betreuteId } } }),
|
||||
},
|
||||
{
|
||||
name: 'immobilien_checkpunkt',
|
||||
modell: null,
|
||||
laden: () => db.immobilienCheckpunkt.findMany({ where: { immobilie: { betreuteId } } }),
|
||||
},
|
||||
{ name: 'vertrag', modell: 'Vertrag', laden: () => db.vertrag.findMany({ where: { betreuteId } }) },
|
||||
{ name: 'kontakt', modell: 'Kontakt', laden: () => db.kontakt.findMany({ where: { betreuteId } }) },
|
||||
{
|
||||
name: 'kontaktprotokoll',
|
||||
modell: 'Kontaktprotokoll',
|
||||
laden: () => db.kontaktprotokoll.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{
|
||||
name: 'gesundheitsereignis',
|
||||
modell: 'Gesundheitsereignis',
|
||||
laden: () => db.gesundheitsereignis.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{
|
||||
name: 'heimvertrag',
|
||||
modell: 'Heimvertrag',
|
||||
laden: () => db.heimvertrag.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{
|
||||
name: 'heimkostenmonat',
|
||||
modell: null,
|
||||
laden: () => db.heimkostenmonat.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{
|
||||
name: 'sozialhilfe_antrag',
|
||||
modell: 'SozialhilfeAntrag',
|
||||
laden: () => db.sozialhilfeAntrag.findMany({ where: { betreuteId } }),
|
||||
},
|
||||
{
|
||||
name: 'nachforderung',
|
||||
modell: null,
|
||||
laden: () => db.nachforderung.findMany({ where: { antrag: { betreuteId } } }),
|
||||
},
|
||||
{
|
||||
name: 'bescheid',
|
||||
modell: 'Bescheid',
|
||||
laden: () => db.bescheid.findMany({ where: { antrag: { betreuteId } } }),
|
||||
},
|
||||
{ name: 'leistung', modell: null, laden: () => db.leistung.findMany({ where: { betreuteId } }) },
|
||||
{ name: 'bericht', modell: 'Bericht', laden: () => db.bericht.findMany({ where: { betreuteId } }) },
|
||||
]
|
||||
|
||||
if (optionen.mitAuditLog) {
|
||||
liste.push({
|
||||
name: 'audit_log',
|
||||
modell: 'AuditLog',
|
||||
laden: () => db.auditLog.findMany({ where: { betreuteId }, orderBy: { zeitpunkt: 'asc' } }),
|
||||
})
|
||||
}
|
||||
|
||||
return liste
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LIESMICH
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function liesmichText(angaben: {
|
||||
betreuteName: string
|
||||
erstelltVon: string
|
||||
erstelltAm: Date
|
||||
tabellen: string[]
|
||||
anzahlDokumente: number
|
||||
anzahlBerichte: number
|
||||
mitAuditLog: boolean
|
||||
}): string {
|
||||
return [
|
||||
'Vollexport aus dem Betreuungs-Cockpit',
|
||||
'=====================================',
|
||||
'',
|
||||
`Betreute Person: ${angaben.betreuteName}`,
|
||||
`Erzeugt am: ${formatDatumZeit(angaben.erstelltAm)}`,
|
||||
`Erzeugt von: ${angaben.erstelltVon}`,
|
||||
'',
|
||||
'Was ist hier drin?',
|
||||
'------------------',
|
||||
'',
|
||||
'dokumente/<jahr>/<datum>_<absender>_<titel>.<endung>',
|
||||
' Alle hochgeladenen Originaldateien - Bescheide, Kontoauszüge, Rechnungen,',
|
||||
' Beschlüsse, Fotos. Unverändert so, wie sie eingegangen sind.',
|
||||
` Enthalten: ${angaben.anzahlDokumente} Dateien.`,
|
||||
'',
|
||||
'daten/*.json',
|
||||
' Der komplette Datenbestand als JSON, eine Datei je Tabelle. Verschlüsselte',
|
||||
' Felder sind hier im Klartext enthalten (z.B. "notizen" statt',
|
||||
' "notizenVerschluesselt"). Geldbeträge stehen als Zeichenkette in CENT -',
|
||||
' "123456" bedeutet 1.234,56 Euro. Datumsangaben sind ISO-8601 (UTC).',
|
||||
` Enthalten: ${angaben.tabellen.join(', ')}.`,
|
||||
angaben.mitAuditLog
|
||||
? ' Das Audit-Log protokolliert, wer wann was geändert hat.'
|
||||
: ' Das Audit-Log wurde für diesen Export abgewählt.',
|
||||
'',
|
||||
'berichte/*.pdf',
|
||||
' Die erzeugten und abgelegten Berichte - Jahresberichte, Vermögensverzeichnis,',
|
||||
` Belegmappen, Unterlagenpakete. Enthalten: ${angaben.anzahlBerichte} Dateien.`,
|
||||
'',
|
||||
'pruefsummen.txt',
|
||||
' SHA-256 je Datei in diesem Archiv, im Format "<hash> <pfad>". Damit lässt',
|
||||
' sich jederzeit belegen, dass eine Datei unverändert ist. Prüfen kannst du',
|
||||
' das unter Linux/macOS mit "shasum -a 256 -c pruefsummen.txt", unter Windows',
|
||||
' mit "certutil -hashfile <datei> SHA256".',
|
||||
'',
|
||||
'Wie arbeite ich ohne diese Anwendung weiter?',
|
||||
'-------------------------------------------',
|
||||
'',
|
||||
'1. Entpacke das Archiv in einen Ordner, den du sicherst (Backup!). Die Daten',
|
||||
' sind hier NICHT mehr verschlüsselt - behandle den Ordner so vertraulich wie',
|
||||
' den Aktenordner selbst.',
|
||||
'2. Die Ordnerstruktur unter dokumente/ ist nach Jahren sortiert und selbst',
|
||||
' erklärend. Für das Betreuungsgericht und den Sozialhilfeträger reicht sie',
|
||||
' als Aktenführung aus.',
|
||||
'3. Die JSON-Dateien öffnest du mit jedem Texteditor. Wer eine Tabelle daraus',
|
||||
' machen will, lädt sie in LibreOffice Calc oder Excel (Daten -> Aus Datei ->',
|
||||
' JSON) oder nutzt ein Werkzeug wie "jq".',
|
||||
'4. Die laufenden Pflichten bleiben bestehen, auch ohne Software: Jahresbericht',
|
||||
' nach § 1863 BGB, Vermögensübersicht nach § 1859 BGB, die Aufwandspauschale',
|
||||
' nach § 1878 BGB innerhalb von sechs Monaten nach Ende des Betreuungsjahres',
|
||||
' (Ausschlussfrist!). Übertrage die Fristen aus daten/frist.json in deinen',
|
||||
' Kalender, bevor du die Anwendung abschaltest.',
|
||||
'',
|
||||
'Dieses Archiv ist keine Rechtsberatung und ersetzt keine Rücksprache mit dem',
|
||||
'Betreuungsgericht, dem Sozialhilfeträger oder einem Anwalt.',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Der Export selbst
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export async function vollexportStroemen(optionen: VollexportOptionen): Promise<VollexportErgebnis> {
|
||||
const erstelltAm = optionen.erstelltAm ?? new Date()
|
||||
|
||||
const betreute = await db.betreute.findFirstOrThrow({
|
||||
where: { id: optionen.betreuteId, ...nichtGeloescht },
|
||||
select: { vorname: true, nachname: true, anrede: true },
|
||||
})
|
||||
const betreuteName = [betreute.anrede, betreute.vorname, betreute.nachname]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const archiv = archiver('zip', { zlib: { level: 9 } })
|
||||
// Fehlende Dateien im Objektspeicher duerfen den Lauf nicht abbrechen.
|
||||
archiv.on('warning', (warnung) => {
|
||||
console.warn('[vollexport] Warnung beim Archivieren:', warnung.message)
|
||||
})
|
||||
|
||||
const dateiname = berichtDateiname('vollexport', betreute.nachname, erstelltAm, 'zip')
|
||||
|
||||
// Der Aufbau laeuft im Hintergrund weiter, waehrend der Aufrufer bereits
|
||||
// den Strom weiterreicht - genau das macht den Export streamfaehig.
|
||||
void befuellen(archiv, optionen, { betreuteName, erstelltAm }).catch((fehler: Error) => {
|
||||
console.error('[vollexport] Abbruch:', fehler)
|
||||
archiv.abort()
|
||||
})
|
||||
|
||||
return { dateiname, strom: archiv }
|
||||
}
|
||||
|
||||
async function befuellen(
|
||||
archiv: Archiv,
|
||||
optionen: VollexportOptionen,
|
||||
zusatz: { betreuteName: string; erstelltAm: Date },
|
||||
): Promise<void> {
|
||||
const pruefsummen: { pfad: string; hash: string }[] = []
|
||||
const vergeben = new Set<string>()
|
||||
|
||||
/** Verhindert, dass zwei Dokumente mit gleichem Titel einander verdecken. */
|
||||
const eindeutig = (pfad: string): string => {
|
||||
if (!vergeben.has(pfad)) {
|
||||
vergeben.add(pfad)
|
||||
return pfad
|
||||
}
|
||||
const punkt = pfad.lastIndexOf('.')
|
||||
const stamm = punkt < 0 ? pfad : pfad.slice(0, punkt)
|
||||
const endung = punkt < 0 ? '' : pfad.slice(punkt)
|
||||
let n = 2
|
||||
while (vergeben.has(`${stamm}-${n}${endung}`)) n++
|
||||
const neu = `${stamm}-${n}${endung}`
|
||||
vergeben.add(neu)
|
||||
return neu
|
||||
}
|
||||
|
||||
const schreiben = async (pfad: string, inhalt: Buffer | string) => {
|
||||
const ziel = eindeutig(pfadErfordern(pfad))
|
||||
pruefsummen.push({ pfad: ziel, hash: sha256(inhalt) })
|
||||
await anhaengen(archiv, ziel, inhalt)
|
||||
}
|
||||
|
||||
// --- 1. Daten ------------------------------------------------------------
|
||||
const verwendeteTabellen: string[] = []
|
||||
for (const tabelle of tabellen(optionen.betreuteId, optionen)) {
|
||||
const datensaetze = (await tabelle.laden()) as Record<string, unknown>[]
|
||||
const offen = entschluesseln(tabelle.modell, datensaetze)
|
||||
await schreiben(`daten/${sichererDateiname(tabelle.name, 'tabelle')}.json`, alsJson(offen))
|
||||
verwendeteTabellen.push(tabelle.name)
|
||||
}
|
||||
|
||||
// --- 2. Dokumente --------------------------------------------------------
|
||||
let anzahlDokumente = 0
|
||||
if (optionen.mitDokumenten) {
|
||||
const dokumente = await db.dokument.findMany({
|
||||
where: { betreuteId: optionen.betreuteId, objektSchluessel: { not: null } },
|
||||
select: {
|
||||
titel: true,
|
||||
absender: true,
|
||||
schreibenVom: true,
|
||||
eingangAm: true,
|
||||
erstelltAm: true,
|
||||
objektSchluessel: true,
|
||||
dateiname: true,
|
||||
mimeTyp: true,
|
||||
},
|
||||
orderBy: { erstelltAm: 'asc' },
|
||||
})
|
||||
|
||||
for (const dokument of dokumente) {
|
||||
if (!dokument.objektSchluessel) continue
|
||||
let inhalt: Buffer
|
||||
try {
|
||||
inhalt = await lesen(dokument.objektSchluessel)
|
||||
} catch (fehler) {
|
||||
console.warn(
|
||||
`[vollexport] Datei zu "${dokument.titel}" nicht lesbar, wird übersprungen:`,
|
||||
(fehler as Error).message,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const pfad = exportDokumentPfad({
|
||||
datum: dokument.schreibenVom ?? dokument.eingangAm ?? dokument.erstelltAm,
|
||||
absender: dokument.absender,
|
||||
titel: dokument.titel,
|
||||
dateiname: dokument.dateiname,
|
||||
mimeTyp: dokument.mimeTyp,
|
||||
})
|
||||
await schreiben(pfad, inhalt)
|
||||
anzahlDokumente++
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Berichte ---------------------------------------------------------
|
||||
let anzahlBerichte = 0
|
||||
if (optionen.mitBerichten) {
|
||||
const berichte = await db.bericht.findMany({
|
||||
where: { betreuteId: optionen.betreuteId, objektSchluessel: { not: null } },
|
||||
select: { titel: true, art: true, dateiname: true, objektSchluessel: true, erstelltAm: true },
|
||||
orderBy: { erstelltAm: 'asc' },
|
||||
})
|
||||
|
||||
for (const bericht of berichte) {
|
||||
if (!bericht.objektSchluessel) continue
|
||||
let inhalt: Buffer
|
||||
try {
|
||||
inhalt = await lesen(bericht.objektSchluessel)
|
||||
} catch (fehler) {
|
||||
console.warn(
|
||||
`[vollexport] Bericht "${bericht.titel}" nicht lesbar, wird übersprungen:`,
|
||||
(fehler as Error).message,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const name = bericht.dateiname
|
||||
? sichererDateiname(bericht.dateiname.replace(/\.[a-z0-9]+$/i, ''), 'bericht')
|
||||
: sichererDateiname(`${bericht.art}-${bericht.titel}`, 'bericht')
|
||||
await schreiben(
|
||||
`berichte/${bericht.erstelltAm.toISOString().slice(0, 10)}_${name}.pdf`,
|
||||
inhalt,
|
||||
)
|
||||
anzahlBerichte++
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. LIESMICH und Pruefsummen ----------------------------------------
|
||||
await schreiben(
|
||||
'LIESMICH.txt',
|
||||
liesmichText({
|
||||
betreuteName: zusatz.betreuteName,
|
||||
erstelltVon: optionen.erstelltVon,
|
||||
erstelltAm: zusatz.erstelltAm,
|
||||
tabellen: verwendeteTabellen,
|
||||
anzahlDokumente,
|
||||
anzahlBerichte,
|
||||
mitAuditLog: optionen.mitAuditLog,
|
||||
}),
|
||||
)
|
||||
|
||||
// Die Pruefsummendatei steht zuletzt und listet alles davor - sie selbst
|
||||
// kann sich naturgemaess nicht enthalten.
|
||||
const zeilen = [
|
||||
`# SHA-256 aller Dateien in diesem Archiv, erzeugt am ${formatDatum(zusatz.erstelltAm)}.`,
|
||||
'# Prüfen: shasum -a 256 -c pruefsummen.txt',
|
||||
...pruefsummen.map((eintrag) => `${eintrag.hash} ${eintrag.pfad}`),
|
||||
'',
|
||||
].join('\n')
|
||||
await anhaengen(archiv, 'pruefsummen.txt', zeilen)
|
||||
|
||||
await archiv.finalize()
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'server-only'
|
||||
import * as React from 'react'
|
||||
import { Document, Text, View } from '@react-pdf/renderer'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { ART_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import {
|
||||
bildAlsPdf,
|
||||
paginieren,
|
||||
pdfErzeugen,
|
||||
pdfsZusammenfuehren,
|
||||
seitenzahl as seitenzahlVon,
|
||||
} from '@/lib/pdf/erzeugen'
|
||||
import { Abschnitt, Angabe, Berichtsseite, Hinweiskasten, Tabelle, type Berichtskopf } from '@/lib/pdf/rahmen'
|
||||
import { typErkennen } from '@/lib/speicher/dateityp'
|
||||
import { lesen } from '@/lib/speicher'
|
||||
|
||||
/**
|
||||
* Belegmappe: alle Dokumente eines Vorgangs oder eines Zeitraums als **eine**
|
||||
* chronologisch sortierte, durchgehend paginierte PDF.
|
||||
*
|
||||
* Genau das wollen Gericht und Bezirk sehen - nicht dreissig einzelne
|
||||
* Anhaenge, sondern ein Heft mit Deckblatt, Inhaltsverzeichnis und
|
||||
* fortlaufender Seitenzahl, auf die man sich im Bericht berufen kann
|
||||
* ("Beleg 7, Seite 23").
|
||||
*
|
||||
* Bilder (abfotografierte Belege) werden in einseitige PDF-Seiten gewandelt,
|
||||
* damit die Mappe in einem Stueck bleibt.
|
||||
*/
|
||||
|
||||
export type Belegmappenauswahl =
|
||||
| { art: 'vorgang'; vorgangId: string }
|
||||
| { art: 'zeitraum'; von: Date; bis: Date }
|
||||
| { art: 'auswahl'; dokumentIds: string[] }
|
||||
|
||||
export type Mappenergebnis = {
|
||||
pdf: Buffer
|
||||
dateiname: string
|
||||
/** Wie viele Dokumente sind in der Mappe gelandet? */
|
||||
anzahl: number
|
||||
/** Was konnte nicht aufgenommen werden - steht auch im Deckblatt. */
|
||||
hinweise: string[]
|
||||
}
|
||||
|
||||
type Belegzeile = {
|
||||
nummer: number
|
||||
dokumentId: string
|
||||
datum: Date | null
|
||||
absender: string
|
||||
art: string
|
||||
titel: string
|
||||
aktenzeichen: string | null
|
||||
ablageortPapier: string | null
|
||||
pruefsumme: string | null
|
||||
seiteVon: number
|
||||
seiten: number
|
||||
}
|
||||
|
||||
/** Das Datum, nach dem chronologisch sortiert wird. */
|
||||
function massgeblichesDatum(dokument: {
|
||||
eingangAm: Date | null
|
||||
schreibenVom: Date | null
|
||||
erstelltAm: Date
|
||||
}): Date {
|
||||
return dokument.eingangAm ?? dokument.schreibenVom ?? dokument.erstelltAm
|
||||
}
|
||||
|
||||
async function dokumenteHolen(betreuteId: string, auswahl: Belegmappenauswahl) {
|
||||
const gemeinsam = { betreuteId, ...nichtGeloescht, objektSchluessel: { not: null } } as const
|
||||
|
||||
if (auswahl.art === 'vorgang') {
|
||||
return db.dokument.findMany({
|
||||
where: { ...gemeinsam, verknuepfungen: { some: { vorgangId: auswahl.vorgangId } } },
|
||||
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
if (auswahl.art === 'auswahl') {
|
||||
return db.dokument.findMany({
|
||||
where: { ...gemeinsam, id: { in: auswahl.dokumentIds.slice(0, 200) } },
|
||||
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
return db.dokument.findMany({
|
||||
where: {
|
||||
...gemeinsam,
|
||||
OR: [
|
||||
{ eingangAm: { gte: auswahl.von, lte: auswahl.bis } },
|
||||
{ eingangAm: null, schreibenVom: { gte: auswahl.von, lte: auswahl.bis } },
|
||||
{ eingangAm: null, schreibenVom: null, erstelltAm: { gte: auswahl.von, lte: auswahl.bis } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ eingangAm: 'asc' }, { schreibenVom: 'asc' }, { erstelltAm: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Deckblatt und Inhaltsverzeichnis
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function Deckblatt({
|
||||
kopf,
|
||||
zeilen,
|
||||
anzahlSeiten,
|
||||
hinweise,
|
||||
beschreibung,
|
||||
}: {
|
||||
kopf: Berichtskopf
|
||||
zeilen: Belegzeile[]
|
||||
anzahlSeiten: number
|
||||
hinweise: string[]
|
||||
beschreibung: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Document
|
||||
title={`Belegmappe – ${kopf.betreute.name}`}
|
||||
author={kopf.erstelltVon}
|
||||
creator="Betreuungs-Cockpit"
|
||||
producer="Betreuungs-Cockpit"
|
||||
>
|
||||
<Berichtsseite kopf={kopf} mitUnterschrift>
|
||||
<Abschnitt titel="Inhalt dieser Mappe">
|
||||
<Angabe bezeichnung="Zusammenstellung" wert={beschreibung} />
|
||||
<Angabe bezeichnung="Anzahl Belege" wert={String(zeilen.length)} />
|
||||
<Angabe
|
||||
bezeichnung="Umfang"
|
||||
wert={`${anzahlSeiten} Seite${anzahlSeiten === 1 ? '' : 'n'} einschließlich dieses Deckblatts`}
|
||||
/>
|
||||
<Angabe bezeichnung="Zusammengestellt am" wert={formatDatum(kopf.erstelltAm)} />
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt titel="Verzeichnis der Belege">
|
||||
<Tabelle<Belegzeile>
|
||||
spalten={[
|
||||
{ kopf: 'Nr.', breite: 0.7, zelle: (z) => String(z.nummer) },
|
||||
{ kopf: 'Datum', breite: 1.5, zelle: (z) => formatDatum(z.datum) },
|
||||
{ kopf: 'Absender', breite: 3, zelle: (z) => z.absender },
|
||||
{ kopf: 'Art', breite: 2, zelle: (z) => z.art },
|
||||
{ kopf: 'Bezeichnung', breite: 4, zelle: (z) => z.titel },
|
||||
{
|
||||
kopf: 'Seite',
|
||||
breite: 1.3,
|
||||
rechts: true,
|
||||
zelle: (z) =>
|
||||
z.seiten > 1 ? `${z.seiteVon}–${z.seiteVon + z.seiten - 1}` : String(z.seiteVon),
|
||||
},
|
||||
]}
|
||||
zeilen={zeilen}
|
||||
leerText="Für diese Auswahl liegen keine Belege vor."
|
||||
/>
|
||||
</Abschnitt>
|
||||
|
||||
{zeilen.some((z) => z.ablageortPapier) && (
|
||||
<Abschnitt titel="Ablageort der Originale">
|
||||
<Tabelle<Belegzeile>
|
||||
spalten={[
|
||||
{ kopf: 'Nr.', breite: 0.7, zelle: (z) => String(z.nummer) },
|
||||
{ kopf: 'Bezeichnung', breite: 5, zelle: (z) => z.titel },
|
||||
{ kopf: 'Ordner / Register', breite: 4, zelle: (z) => z.ablageortPapier ?? '–' },
|
||||
]}
|
||||
zeilen={zeilen.filter((z) => z.ablageortPapier)}
|
||||
/>
|
||||
</Abschnitt>
|
||||
)}
|
||||
|
||||
{hinweise.length > 0 && (
|
||||
<Abschnitt titel="Hinweise">
|
||||
{hinweise.map((hinweis, i) => (
|
||||
<Text key={i} style={{ marginBottom: 3 }}>
|
||||
• {hinweis}
|
||||
</Text>
|
||||
))}
|
||||
</Abschnitt>
|
||||
)}
|
||||
|
||||
<View>
|
||||
<Hinweiskasten>
|
||||
Die Belege sind Kopien der bei der Betreuung eingegangenen Unterlagen. Zu jedem Beleg
|
||||
ist im Betreuungs-Cockpit eine SHA-256-Prüfsumme gespeichert; damit lässt sich
|
||||
nachweisen, dass die Datei seit der Aufnahme in die Akte nicht verändert wurde. Die
|
||||
Originale in Papierform sind unter dem oben genannten Ablageort abgeheftet.
|
||||
</Hinweiskasten>
|
||||
</View>
|
||||
</Berichtsseite>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Zusammenbau
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stellt die Mappe zusammen.
|
||||
*
|
||||
* Das Deckblatt wird zweimal gerendert: einmal, um seinen eigenen Umfang zu
|
||||
* kennen, und dann noch einmal mit den richtigen Seitenzahlen. Anders geht
|
||||
* es nicht - das Verzeichnis muss auf Seiten verweisen, die es selbst
|
||||
* mitverschiebt.
|
||||
*/
|
||||
export async function belegmappeErzeugen(
|
||||
betreuteId: string,
|
||||
auswahl: Belegmappenauswahl,
|
||||
erstelltVon: string,
|
||||
): Promise<Mappenergebnis> {
|
||||
const betreute = await db.betreute.findFirst({
|
||||
where: { id: betreuteId, ...nichtGeloescht },
|
||||
select: {
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
geburtsdatum: true,
|
||||
aktenzeichenGericht: true,
|
||||
betreuungsgericht: true,
|
||||
},
|
||||
})
|
||||
if (!betreute) throw new Error('Diese betreute Person gibt es nicht.')
|
||||
|
||||
const dokumente = await dokumenteHolen(betreuteId, auswahl)
|
||||
const hinweise: string[] = []
|
||||
|
||||
// Jedes Dokument in ein PDF wandeln und seinen Umfang bestimmen.
|
||||
const teile: { zeile: Omit<Belegzeile, 'nummer' | 'seiteVon'>; pdf: Buffer }[] = []
|
||||
|
||||
for (const dokument of dokumente) {
|
||||
if (!dokument.objektSchluessel) continue
|
||||
try {
|
||||
const inhalt = await lesen(dokument.objektSchluessel)
|
||||
const typ = typErkennen(inhalt)
|
||||
if (!typ) {
|
||||
hinweise.push(`„${dokument.titel}“ konnte nicht gelesen werden und fehlt in dieser Mappe.`)
|
||||
continue
|
||||
}
|
||||
|
||||
const alsPdf = typ.istBild
|
||||
? await bildAlsPdf(
|
||||
inhalt,
|
||||
`${dokument.titel} · ${formatDatum(massgeblichesDatum(dokument))}`,
|
||||
)
|
||||
: inhalt
|
||||
|
||||
const seiten = await seitenzahlVon(alsPdf)
|
||||
|
||||
teile.push({
|
||||
pdf: alsPdf,
|
||||
zeile: {
|
||||
dokumentId: dokument.id,
|
||||
datum: dokument.eingangAm ?? dokument.schreibenVom,
|
||||
absender: dokument.absender ?? '–',
|
||||
art: ART_BESCHRIFTUNG[dokument.art],
|
||||
titel: dokument.titel,
|
||||
aktenzeichen: dokument.aktenzeichen,
|
||||
ablageortPapier: dokument.ablageortPapier,
|
||||
pruefsumme: dokument.pruefsumme,
|
||||
seiten,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
console.error('[belegmappe] Dokument übersprungen:', dokument.id, fehler)
|
||||
hinweise.push(
|
||||
`„${dokument.titel}“ ließ sich nicht in die Mappe übernehmen (Datei beschädigt oder verschlüsselt).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const kopf: Berichtskopf = {
|
||||
titel: 'Belegmappe',
|
||||
betreute: {
|
||||
name: `${betreute.vorname} ${betreute.nachname}`,
|
||||
geburtsdatum: betreute.geburtsdatum,
|
||||
},
|
||||
behoerde: betreute.betreuungsgericht ?? undefined,
|
||||
aktenzeichen: betreute.aktenzeichenGericht ?? undefined,
|
||||
zeitraumVon: auswahl.art === 'zeitraum' ? auswahl.von : null,
|
||||
zeitraumBis: auswahl.art === 'zeitraum' ? auswahl.bis : null,
|
||||
rechtsgrundlage: '§ 1865 BGB (Rechnungslegung)',
|
||||
erstelltAm: new Date(),
|
||||
erstelltVon,
|
||||
}
|
||||
|
||||
const beschreibung = beschreibungFuer(auswahl)
|
||||
|
||||
// Zwei Durchgaenge, damit die Seitenzahlen im Verzeichnis stimmen.
|
||||
let deckblattSeiten = 1
|
||||
let deckblatt: Buffer = Buffer.alloc(0)
|
||||
|
||||
for (let durchgang = 0; durchgang < 3; durchgang++) {
|
||||
const zeilen = zeilenNummerieren(teile, deckblattSeiten)
|
||||
const gesamt = deckblattSeiten + teile.reduce((summe, teil) => summe + teil.zeile.seiten, 0)
|
||||
|
||||
deckblatt = await pdfErzeugen(
|
||||
<Deckblatt
|
||||
kopf={kopf}
|
||||
zeilen={zeilen}
|
||||
anzahlSeiten={gesamt}
|
||||
hinweise={hinweise}
|
||||
beschreibung={beschreibung}
|
||||
/>,
|
||||
)
|
||||
|
||||
const gemessen = await seitenzahlVon(deckblatt)
|
||||
if (gemessen === deckblattSeiten) break
|
||||
deckblattSeiten = gemessen
|
||||
}
|
||||
|
||||
const zusammengefuehrt = await pdfsZusammenfuehren([deckblatt, ...teile.map((t) => t.pdf)])
|
||||
|
||||
const fusszeile = `Belegmappe · ${betreute.vorname} ${betreute.nachname}${
|
||||
betreute.aktenzeichenGericht ? ` · Az. ${betreute.aktenzeichenGericht}` : ''
|
||||
} · erstellt am ${formatDatum(kopf.erstelltAm)}`
|
||||
|
||||
const pdf = await paginieren(zusammengefuehrt, fusszeile)
|
||||
|
||||
return {
|
||||
pdf,
|
||||
dateiname: dateinamenFuer(auswahl, betreute.nachname),
|
||||
anzahl: teile.length,
|
||||
hinweise,
|
||||
}
|
||||
}
|
||||
|
||||
function zeilenNummerieren(
|
||||
teile: { zeile: Omit<Belegzeile, 'nummer' | 'seiteVon'> }[],
|
||||
ersteSeite: number,
|
||||
): Belegzeile[] {
|
||||
let seite = ersteSeite + 1
|
||||
return teile.map((teil, i) => {
|
||||
const zeile: Belegzeile = { ...teil.zeile, nummer: i + 1, seiteVon: seite }
|
||||
seite += teil.zeile.seiten
|
||||
return zeile
|
||||
})
|
||||
}
|
||||
|
||||
function beschreibungFuer(auswahl: Belegmappenauswahl): string {
|
||||
if (auswahl.art === 'vorgang') return 'Alle Belege zu einem Vorgang'
|
||||
if (auswahl.art === 'auswahl') return 'Von Hand zusammengestellte Belege'
|
||||
return `Belege im Zeitraum ${formatDatum(auswahl.von)} bis ${formatDatum(auswahl.bis)}`
|
||||
}
|
||||
|
||||
function dateinamenFuer(auswahl: Belegmappenauswahl, nachname: string): string {
|
||||
const teil =
|
||||
auswahl.art === 'zeitraum'
|
||||
? `${auswahl.von.toISOString().slice(0, 10)}_bis_${auswahl.bis.toISOString().slice(0, 10)}`
|
||||
: auswahl.art === 'vorgang'
|
||||
? 'vorgang'
|
||||
: 'auswahl'
|
||||
|
||||
const sauber = nachname.replace(/[^A-Za-z0-9]/g, '') || 'Akte'
|
||||
return `Belegmappe_${sauber}_${teil}.pdf`
|
||||
}
|
||||
@@ -124,6 +124,39 @@ export function normalisieren(text: string): string {
|
||||
.replace(/ß/g, 'ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleichsform fuer den Namensabgleich: kleingeschrieben, ohne Umlaute,
|
||||
* und jede Folge von Satz- oder Leerzeichen wird zu einem Leerzeichen.
|
||||
*
|
||||
* Damit findet "Bezirk Oberfranken, Sozialverwaltung" aus den Kontakten
|
||||
* auch den Briefkopf, in dem beides in zwei Zeilen ohne Komma steht.
|
||||
*
|
||||
* Der Index zeigt fuer jedes Zeichen der Vergleichsform auf die Stelle im
|
||||
* Ursprungstext - so laesst sich hinterher die Textstelle ausschneiden.
|
||||
*/
|
||||
export function vergleichsform(text: string): { norm: string; index: number[] } {
|
||||
let norm = ''
|
||||
const index: number[] = []
|
||||
let trennerOffen = true
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ersetzt = normalisieren(text[i]!)
|
||||
if (/^[a-z0-9]+$/.test(ersetzt)) {
|
||||
for (const zeichen of ersetzt) {
|
||||
norm += zeichen
|
||||
index.push(i)
|
||||
}
|
||||
trennerOffen = false
|
||||
} else if (!trennerOffen) {
|
||||
norm += ' '
|
||||
index.push(i)
|
||||
trennerOffen = true
|
||||
}
|
||||
}
|
||||
|
||||
return { norm, index }
|
||||
}
|
||||
|
||||
function jahrVervollstaendigen(roh: string): number {
|
||||
const zahl = Number.parseInt(roh, 10)
|
||||
if (roh.length === 4) return zahl
|
||||
@@ -287,7 +320,7 @@ const AZ_MUSTER: AzMuster[] = [
|
||||
{
|
||||
// "Az.: XVII 123/25", "Aktenzeichen: 4711/26", "Unser Zeichen: AB/12345"
|
||||
regex:
|
||||
/(?:Aktenzeichen|Az\.?|Gesch\.?[-\s]?Z\.?|Geschäftszeichen|Geschaeftszeichen|Unser Zeichen|Ihr Zeichen)\s*:?\s*([A-Za-zÄÖÜäöü0-9][A-Za-zÄÖÜäöü0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$|\s*(?:Ihr|Unser|Datum|Seite|Betreff)\b)/g,
|
||||
/\b(?:Aktenzeichen|Az\b\.?|Gesch\.?[-\s]?Z\b\.?|Geschäftszeichen|Geschaeftszeichen|Unser Zeichen|Ihr Zeichen)\s*:?\s*([A-Za-zÄÖÜäöü0-9][A-Za-zÄÖÜäöü0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$|\s*(?:Ihr|Unser|Datum|Seite|Betreff)\b)/g,
|
||||
sicherheit: 'hoch',
|
||||
gruppe: 1,
|
||||
},
|
||||
@@ -299,8 +332,10 @@ const AZ_MUSTER: AzMuster[] = [
|
||||
gruppe: 1,
|
||||
},
|
||||
{
|
||||
// Betreuungsgericht: "XVII 123/25", auch "XVII 0123/2025"
|
||||
regex: /\b((?:X{0,3}(?:IX|IV|V?I{0,3}))\s?\d{1,5}\s*\/\s*\d{2,4})\b/g,
|
||||
// Betreuungsgericht: "XVII 123/25", auch "XVII 0123/2025".
|
||||
// Die roemische Zahl ist Pflicht - sonst wuerde jedes "12/2025" im
|
||||
// Text als Aktenzeichen durchgehen.
|
||||
regex: /\b([IVXL]{1,6}\s?\d{1,5}\s*\/\s*\d{2,4})\b/g,
|
||||
sicherheit: 'mittel',
|
||||
gruppe: 1,
|
||||
},
|
||||
@@ -477,22 +512,37 @@ export function absenderErkennen(
|
||||
bekannte: string[] = [],
|
||||
): Vorschlag<string> | null {
|
||||
const kopfbereich = text.slice(0, 1200)
|
||||
const normKopf = normalisieren(kopfbereich)
|
||||
const kopfForm = vergleichsform(kopfbereich)
|
||||
|
||||
// 1. Abgleich mit bekannten Absendern
|
||||
let bester: { name: string; stelle: number } | null = null
|
||||
// 1. Abgleich mit bekannten Absendern. Wer schon einmal geschrieben hat,
|
||||
// schreibt wieder - deshalb schlaegt das jede Heuristik.
|
||||
let bester: { name: string; stelle: number; laenge: number } | null = null
|
||||
for (const name of bekannte) {
|
||||
const gekuerzt = name.trim()
|
||||
if (gekuerzt.length < 4) continue
|
||||
const stelle = normKopf.indexOf(normalisieren(gekuerzt))
|
||||
if (stelle >= 0 && (!bester || gekuerzt.length > bester.name.length)) {
|
||||
bester = { name: gekuerzt, stelle }
|
||||
|
||||
const gesucht = vergleichsform(gekuerzt).norm.trim()
|
||||
if (gesucht.length < 4) continue
|
||||
|
||||
const treffer = kopfForm.norm.indexOf(gesucht)
|
||||
if (treffer < 0) continue
|
||||
if (bester && gekuerzt.length <= bester.laenge) continue
|
||||
|
||||
bester = {
|
||||
name: gekuerzt,
|
||||
stelle: kopfForm.index[treffer] ?? 0,
|
||||
laenge: gekuerzt.length,
|
||||
}
|
||||
}
|
||||
if (bester) {
|
||||
return {
|
||||
wert: bester.name,
|
||||
textstelle: textstelleSchneiden(kopfbereich, bester.stelle, bester.stelle + bester.name.length, 40),
|
||||
textstelle: textstelleSchneiden(
|
||||
kopfbereich,
|
||||
bester.stelle,
|
||||
bester.stelle + bester.laenge,
|
||||
40,
|
||||
),
|
||||
sicherheit: 'hoch',
|
||||
}
|
||||
}
|
||||
@@ -576,17 +626,26 @@ function anzahlLesen(roh: string): number | null {
|
||||
return wort ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fristen werden ueber date-fns gerechnet - aber auf einem Datum in der
|
||||
* lokalen Zeitzone, mittags. Sonst verschoebe die Sommerzeit eine Frist um
|
||||
* einen Tag: der 12.03. (Winterzeit) plus vier Wochen ist der 09.04.
|
||||
* (Sommerzeit), und wer das in UTC rechnet, landet auf dem 08.04. Genau
|
||||
* dieser Fehler kostet im Ernstfall einen Widerspruch.
|
||||
*/
|
||||
function rechnen(basis: Date, anzahl: number, einheit: Einheit): Date {
|
||||
switch (einheit) {
|
||||
case 'tage':
|
||||
return aufTag(addDays(basis, anzahl))
|
||||
case 'wochen':
|
||||
return aufTag(addWeeks(basis, anzahl))
|
||||
case 'monate':
|
||||
return aufTag(addMonths(basis, anzahl))
|
||||
case 'jahre':
|
||||
return aufTag(addYears(basis, anzahl))
|
||||
}
|
||||
const lokal = new Date(basis.getUTCFullYear(), basis.getUTCMonth(), basis.getUTCDate(), 12)
|
||||
|
||||
const ergebnis =
|
||||
einheit === 'tage'
|
||||
? addDays(lokal, anzahl)
|
||||
: einheit === 'wochen'
|
||||
? addWeeks(lokal, anzahl)
|
||||
: einheit === 'monate'
|
||||
? addMonths(lokal, anzahl)
|
||||
: addYears(lokal, anzahl)
|
||||
|
||||
return tag(ergebnis.getFullYear(), ergebnis.getMonth() + 1, ergebnis.getDate())
|
||||
}
|
||||
|
||||
function einheitText(anzahl: number, einheit: Einheit): string {
|
||||
@@ -666,6 +725,10 @@ export function fristenErkennen(text: string, basis: Date | null): Fristvorschla
|
||||
const fund = datenFinden(rest)[0]
|
||||
if (!fund || fund.von > 3) continue
|
||||
|
||||
// "vom 01.01.2025 bis 31.12.2025" ist ein Zeitraum, keine Frist.
|
||||
const davor = text.slice(Math.max(0, treffer.index - 40), treffer.index)
|
||||
if (/(?:vom|von)\s+[\d.]{4,12}\s*$/i.test(davor)) continue
|
||||
|
||||
const umgebung = text.slice(Math.max(0, treffer.index - 200), ab + 200)
|
||||
const rechtsbehelf = RECHTSBEHELF.test(umgebung)
|
||||
const zahlung = /zahl|betrag|überweis|ueberweis|konto|rechnung/i.test(umgebung)
|
||||
@@ -703,7 +766,11 @@ function entdoppeln(liste: Fristvorschlag[]): Fristvorschlag[] {
|
||||
.slice()
|
||||
.sort((a, b) => rang[a.sicherheit] - rang[b.sicherheit])
|
||||
.filter((vorschlag) => {
|
||||
const schluessel = `${vorschlag.datum?.toISOString() ?? 'offen'}|${normalisieren(vorschlag.formulierung)}`
|
||||
// Zwei Formulierungen, die auf denselben Tag hinauslaufen, sind eine
|
||||
// Frist. Der sicherere Fund gewinnt - deshalb vorher sortiert.
|
||||
const schluessel = vorschlag.datum
|
||||
? vorschlag.datum.toISOString()
|
||||
: normalisieren(vorschlag.formulierung)
|
||||
if (gesehen.has(schluessel)) return false
|
||||
gesehen.add(schluessel)
|
||||
return true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user