diff --git a/docs/schema-wuensche/dashboard.md b/docs/schema-wuensche/dashboard.md new file mode 100644 index 0000000..9f52de0 --- /dev/null +++ b/docs/schema-wuensche/dashboard.md @@ -0,0 +1,120 @@ +# Schema-Wünsche aus Dashboard und Kontakten + +Diese Datei sammelt, was beim Bauen von `/heute` und den Kontakten +aufgefallen ist. `prisma/schema.prisma` wurde **nicht** geändert – alles +hier ist ein Vorschlag für eine spätere Migration, mit der Begründung +daneben. + +--- + +## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich + +**Was ich gebaut habe.** Die Kennzahl „Deckungslücke“ rechnet exakt nach +der Gruppierung im Schema: + +``` +Gesamtkosten = pflegeEigenanteil + unterkunftVerpflegung + + investitionskosten + ausbildungsumlage + zusatzleistungen + +Deckung = pflegekasse + leistungszuschlag + rente + + sonstigeEinkuenfte + sozialhilfe + eigenmittel + +Deckungslücke = Gesamtkosten − Deckung +``` + +**Das Problem.** `pflegeEigenanteilCent` heißt „Eigenanteil“. Der +einrichtungseinheitliche Eigenanteil ist definitionsgemäß **schon um die +Leistung der Pflegekasse nach § 43 SGB XI gekürzt**. Steht in derselben +Zeile zusätzlich `pflegekasseCent` als Deckung, wird dieser Betrag doppelt +gezählt, und das Dashboard weist eine Überdeckung aus, die es nicht gibt. +Dasselbe gilt für `leistungszuschlagCent` (§ 43c SGB XI), der den +Eigenanteil ebenfalls mindert. + +**Wunsch.** Entweder + +- den Kommentar am Feld schärfen: „`pflegeEigenanteilCent` ist der + **Brutto**-Pflegesatz; `pflegekasseCent` und `leistungszuschlagCent` + werden davon abgezogen" – dann stimmt die Rechnung oben; oder +- ein Feld `heimrechnungGesamtCent` ergänzen, das schlicht die Summe der + Heimrechnung trägt, und die Aufteilung nur noch informativ führen. + +Solange das offen ist, sollte das Heim-Modul entweder den Bruttosatz oder +`pflegekasseCent = 0` erfassen – konsequent das eine oder das andere. +Wer die Kachel zum ersten Mal sieht und einen absurden Wert findet, weiß +mit dieser Notiz, woran es liegt. + +--- + +## 2. `Kontaktprotokoll.datum` könnte `@db.Date` sein + +**Was ich gebaut habe.** Das Feld ist `DateTime`. Die Schnellerfassung +kennt nur einen Tag, keine Uhrzeit; gespeichert wird deshalb bewusst +**12:00 UTC**. Um die Mittagszeit herum verschiebt keine Zeitzone den +Eintrag auf den Vor- oder Folgetag, und die Auswertung („an wie vielen +Tagen bestand persönlicher Kontakt?") zählt zuverlässig. + +**Wunsch.** `datum DateTime @db.Date` – dann verschwindet die +12:00-Konvention, und der Typ sagt, was gemeint ist. Fachlich zählt beim +Jahresbericht nach § 1863 BGB der Tag, nicht die Uhrzeit. + +Falls die Uhrzeit später doch gebraucht wird (Zeiterfassung für die +Vergütung), wäre ein zusätzliches `beginnUhrzeit String?` sauberer als das +Datumsfeld doppelt zu belegen. + +--- + +## 3. `Kontakt`: kein Feld für „nicht mehr zuständig" + +Sachbearbeiter wechseln. Heute bleibt nur der Papierkorb – dann ist aber +auch die Gesprächshistorie aus dem Verzeichnis verschwunden, obwohl sie +für den Jahresbericht und für Rückfragen weiter gebraucht wird. + +**Wunsch.** `aktivBis DateTime? @db.Date` oder `aktiv Boolean @default(true)`. +Das Verzeichnis würde inaktive Kontakte einklappen statt löschen, und die +Historie bliebe sichtbar. Ein `nachfolgerId String?` auf denselben Typ +wäre die Kür: „Frau Krämer, jetzt Herr Sandner". + +--- + +## 4. `Nachforderung`: kein `betreuteId` + +`Nachforderung` hängt nur über `antrag` an der betreuten Person. Jede +Abfrage muss deshalb über die Beziehung filtern: + +```ts +where: { antrag: { betreuteId, geloeschtAm: null }, geloeschtAm: null } +``` + +Das funktioniert, ist aber die einzige Stelle im Dashboard, an der die +Zugriffsprüfung nicht direkt auf einer Spalte sitzt. Bei einer späteren +Row-Level-Security in Postgres wäre eine denormalisierte `betreuteId` +(wie bei `DokumentVerknuepfung`) die einfachere Grundlage. + +--- + +## 5. `Vermoegensposten`: kein Kennzeichen „zählt nicht in die Summe" + +Die Kachel „Vermögen" summiert je Posten den jüngsten `Wertstand` und +zieht `VERBINDLICHKEIT` ab. Posten ohne jeden Wertstand werden gesondert +gezählt und ausgewiesen („2 Posten haben noch keinen Wert und fehlen in +dieser Summe") – stillschweigend als 0 zu führen wäre in einer +Rechnungslegung nach § 1865 BGB falsch. + +Offen bleibt der Fall „bewusst nicht bewertbar" (Hausrat, persönliche +Gegenstände). Heute sieht er aus wie „vergessen". + +**Wunsch.** `ohneWertBegruendung String?` – analog zu +`belegFehltBegruendung` bei `Buchung`. Dann kann die Kachel zwischen +„fehlt noch" und „bewusst ohne Wert" unterscheiden. + +--- + +## 6. Kein Modell für die Sachstandsanfrage + +Die Kachel „Wartet auf andere" verlinkt auf +`/vorgaenge/?aktion=sachstandsanfrage`. Ob daraus ein Schreiben wird, +entscheidet das Vorgangsmodul. Wird eine Anfrage verschickt, sollte +`Vorgang.letzterKontaktAm` fortgeschrieben werden – sonst bleibt der +Vorgang für immer rot, obwohl gerade nachgefasst wurde. Das ist kein +Schema-Wunsch, sondern eine Verabredung zwischen den Modulen; sie steht +hier, damit sie nicht verloren geht. diff --git a/src/app/(app)/kontakte/[id]/bearbeiten/page.tsx b/src/app/(app)/kontakte/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..2612ab3 --- /dev/null +++ b/src/app/(app)/kontakte/[id]/bearbeiten/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { KontaktFormular } from '@/components/kontakte/kontakt-formular' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { oeffnen } from '@/lib/crypto/feld' +import { db, nichtGeloescht } from '@/lib/db' + +export const metadata: Metadata = { title: 'Kontakt bearbeiten' } + +export default async function KontaktBearbeitenSeite({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + const benutzer = await erfordereBenutzer(`/kontakte/${id}/bearbeiten`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { betreute } = await erfordereZugang(aktive.id, 'KONTAKTE', 'schreiben') + + const roh = await db.kontakt.findFirst({ + where: { id, betreuteId: betreute.id, ...nichtGeloescht }, + }) + if (!roh) notFound() + + const kontakt = oeffnen('Kontakt', roh) + + return ( +
+ + +
+ ) +} diff --git a/src/app/(app)/kontakte/[id]/page.tsx b/src/app/(app)/kontakte/[id]/page.tsx new file mode 100644 index 0000000..48ff495 --- /dev/null +++ b/src/app/(app)/kontakte/[id]/page.tsx @@ -0,0 +1,186 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' +import { MessageSquarePlus } 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 { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { KontaktKarte } from '@/components/kontakte/kontakt-karte' +import { PapierkorbKnopf } from '@/components/kontakte/papierkorb-knopf' +import { ProtokollListe, type ProtokollZeile } from '@/components/kontakte/protokoll-liste' +import { darf } from '@/lib/auth/rollen' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { oeffnen } from '@/lib/crypto/feld' +import { db, nichtGeloescht } from '@/lib/db' +import { KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte' +import { kontaktInPapierkorb } from '@/server/actions/kontakte' + +export const metadata: Metadata = { title: 'Kontakt' } + +/** + * Ein Kontakt mit seiner Gespraechshistorie. + * + * Die Historie ist der eigentliche Wert der Seite: Wer vor dem naechsten + * Anruf beim Bezirk hierher schaut, weiss, was beim letzten Mal gesagt + * wurde - und unter welchem Aktenzeichen. + */ +export default async function KontaktSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + const benutzer = await erfordereBenutzer(`/kontakte/${id}`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { betreute, zugang } = await erfordereZugang(aktive.id, 'KONTAKTE', 'lesen') + const darfSchreiben = darf(zugang, 'KONTAKTE', 'schreiben') + + const [kontaktRoh, protokolleRoh] = await Promise.all([ + db.kontakt.findFirst({ where: { id, betreuteId: betreute.id, ...nichtGeloescht } }), + db.kontaktprotokoll.findMany({ + where: { kontaktId: id, betreuteId: betreute.id, ...nichtGeloescht }, + orderBy: { datum: 'desc' }, + include: { vorgang: { select: { id: true, titel: true } } }, + }), + ]) + + if (!kontaktRoh) notFound() + + const kontakt = oeffnen('Kontakt', kontaktRoh) + + const zeilen: ProtokollZeile[] = protokolleRoh.map((eintrag) => { + const offen = oeffnen('Kontaktprotokoll', eintrag) + return { + id: eintrag.id, + datum: eintrag.datum, + art: eintrag.art, + dauerMinuten: eintrag.dauerMinuten, + gespraechspartner: eintrag.gespraechspartner, + mitBetreuter: eintrag.mitBetreuter, + inhalt: offen.inhalt ?? null, + wuensche: offen.wuensche ?? null, + folgeaufgaben: offen.folgeaufgaben ?? null, + kontakt: null, + vorgang: eintrag.vorgang, + } + }) + + return ( +
+ + {KONTAKT_ROLLE_BESCHRIFTUNG[kontakt.rolle]} + {kontakt.organisation ? ` · ${kontakt.organisation}` : ''} + + } + pfad={[ + { titel: 'Heute', href: '/heute' }, + { titel: 'Kontakte', href: '/kontakte' }, + { titel: kontakt.name }, + ]} + aktionen={ + darfSchreiben ? ( + <> + + + + ) : undefined + } + /> + + + + Erreichbarkeit + + +
    + +
+
+
+ + {kontakt.notizen && ( + + + + Notizen + + verschlüsselt gespeichert + + + + +

{kontakt.notizen}

+
+
+ )} + +
+

+ Gesprächshistorie ({zeilen.length}) +

+ + {zeilen.length === 0 ? ( + + + Gespräch festhalten + + + ) : undefined + } + /> + ) : ( + + )} +
+ + {darfSchreiben && ( +
+ +
+ )} +
+ ) +} diff --git a/src/app/(app)/kontakte/neu/page.tsx b/src/app/(app)/kontakte/neu/page.tsx new file mode 100644 index 0000000..beffb94 --- /dev/null +++ b/src/app/(app)/kontakte/neu/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { KontaktFormular } from '@/components/kontakte/kontakt-formular' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' + +export const metadata: Metadata = { title: 'Kontakt anlegen' } + +export default async function KontaktNeuSeite() { + const benutzer = await erfordereBenutzer('/kontakte/neu') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { betreute } = await erfordereZugang(aktive.id, 'KONTAKTE', 'schreiben') + + return ( +
+ + +
+ ) +} diff --git a/src/app/(app)/kontakte/page.tsx b/src/app/(app)/kontakte/page.tsx new file mode 100644 index 0000000..548b26d --- /dev/null +++ b/src/app/(app)/kontakte/page.tsx @@ -0,0 +1,141 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { Users } from 'lucide-react' +import type { KontaktRolle } from '@prisma/client' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { KontaktKarte, type KontaktListeneintrag } from '@/components/kontakte/kontakt-karte' +import { darf } from '@/lib/auth/rollen' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { KONTAKT_ROLLEN, KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte' + +export const metadata: Metadata = { title: 'Kontakte' } + +/** + * Das Kontaktverzeichnis. + * + * Gruppiert nach Rolle, nicht alphabetisch: Wenn ich hier hereinschaue, + * suche ich "den Rechtspfleger" oder "die Sachbearbeiterin beim Bezirk" - + * nicht einen Namen, den ich ohnehin nicht mehr weiss. + */ +export default async function KontakteSeite() { + const benutzer = await erfordereBenutzer('/kontakte') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { betreute, zugang } = await erfordereZugang(aktive.id, 'KONTAKTE', 'lesen') + const darfSchreiben = darf(zugang, 'KONTAKTE', 'schreiben') + + // Zwei Abfragen, keine Schleife: die Kontakte selbst und - in einem + // Rutsch - Anzahl und juengstes Datum der Protokolle je Kontakt. + const [kontakte, protokollzahlen] = await Promise.all([ + db.kontakt.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + orderBy: [{ organisation: 'asc' }, { name: 'asc' }], + select: { + id: true, + name: true, + organisation: true, + rolle: true, + telefon: true, + mobil: true, + fax: true, + email: true, + strasse: true, + plz: true, + ort: true, + aktenzeichen: true, + }, + }), + db.kontaktprotokoll.groupBy({ + by: ['kontaktId'], + where: { betreuteId: betreute.id, kontaktId: { not: null }, ...nichtGeloescht }, + _count: { _all: true }, + _max: { datum: true }, + }), + ]) + + const zahlen = new Map( + protokollzahlen.map((zeile) => [ + zeile.kontaktId, + { anzahl: zeile._count._all, letzter: zeile._max.datum }, + ]), + ) + + const eintraege: KontaktListeneintrag[] = kontakte.map((kontakt) => ({ + ...kontakt, + protokolle: zahlen.get(kontakt.id)?.anzahl ?? 0, + letzterKontaktAm: zahlen.get(kontakt.id)?.letzter ?? null, + })) + + const gruppen = KONTAKT_ROLLEN.map((rolle) => ({ + rolle: rolle as KontaktRolle, + eintraege: eintraege.filter((e) => e.rolle === rolle), + })).filter((gruppe) => gruppe.eintraege.length > 0) + + return ( +
+ + + + + ) : ( + + ) + } + /> + + {gruppen.length === 0 ? ( + + Ersten Kontakt anlegen + + ) : undefined + } + /> + ) : ( +
+ {gruppen.map((gruppe) => ( + + + + {KONTAKT_ROLLE_BESCHRIFTUNG[gruppe.rolle]} + + {gruppe.eintraege.length} + + + + +
    + {gruppe.eintraege.map((kontakt) => ( + + ))} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/app/(app)/kontakte/protokoll/[id]/page.tsx b/src/app/(app)/kontakte/protokoll/[id]/page.tsx new file mode 100644 index 0000000..7da283d --- /dev/null +++ b/src/app/(app)/kontakte/protokoll/[id]/page.tsx @@ -0,0 +1,110 @@ +import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { PapierkorbKnopf } from '@/components/kontakte/papierkorb-knopf' +import { ProtokollErfassung } from '@/components/kontakte/protokoll-erfassung' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { oeffnen } from '@/lib/crypto/feld' +import { db, nichtGeloescht } from '@/lib/db' +import { + KONTAKT_ART_BESCHRIFTUNG, + KONTAKT_ROLLE_BESCHRIFTUNG, + tagFuerFormular, +} from '@/lib/validierung/kontakte' +import { protokollInPapierkorb } from '@/server/actions/kontakte' +import { formatDatum } from '@/lib/utils' + +export const metadata: Metadata = { title: 'Eintrag bearbeiten' } + +/** + * Einen bestehenden Eintrag nachbessern - typisch: der Satz, der beim + * Erfassen im Heimflur zu knapp geraten ist, und die Wuensche, die einem + * erst auf der Rueckfahrt wieder einfallen. + */ +export default async function ProtokollBearbeitenSeite({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + const benutzer = await erfordereBenutzer(`/kontakte/protokoll/${id}`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { betreute } = await erfordereZugang(aktive.id, 'KONTAKTE', 'schreiben') + + const [roh, kontakte, vorgaenge] = await Promise.all([ + db.kontaktprotokoll.findFirst({ + where: { id, betreuteId: betreute.id, ...nichtGeloescht }, + }), + db.kontakt.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + orderBy: [{ name: 'asc' }], + select: { id: true, name: true, organisation: true, rolle: true }, + }), + db.vorgang.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + orderBy: [{ aktualisiertAm: 'desc' }], + select: { id: true, titel: true }, + }), + ]) + + if (!roh) notFound() + + const protokoll = oeffnen('Kontaktprotokoll', roh) + + return ( +
+ + + ({ + id: kontakt.id, + bezeichnung: `${kontakt.name}${ + kontakt.organisation ? ` – ${kontakt.organisation}` : '' + } (${KONTAKT_ROLLE_BESCHRIFTUNG[kontakt.rolle]})`, + }))} + vorgaenge={vorgaenge.map((vorgang) => ({ id: vorgang.id, bezeichnung: vorgang.titel }))} + protokoll={{ + id: roh.id, + datum: tagFuerFormular(roh.datum), + art: roh.art, + dauerMinuten: roh.dauerMinuten, + gespraechspartner: roh.gespraechspartner, + mitBetreuter: roh.mitBetreuter, + kontaktId: roh.kontaktId, + vorgangId: roh.vorgangId, + inhalt: protokoll.inhalt ?? null, + wuensche: protokoll.wuensche ?? null, + folgeaufgaben: protokoll.folgeaufgaben ?? null, + }} + weiter="/kontakte/protokoll" + /> + + + +
+ +
+
+ ) +} diff --git a/src/app/(app)/kontakte/protokoll/neu/page.tsx b/src/app/(app)/kontakte/protokoll/neu/page.tsx new file mode 100644 index 0000000..ae997a3 --- /dev/null +++ b/src/app/(app)/kontakte/protokoll/neu/page.tsx @@ -0,0 +1,107 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { ProtokollErfassung, type ProtokollWerte } from '@/components/kontakte/protokoll-erfassung' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte' + +export const metadata: Metadata = { title: 'Kontakt festhalten' } + +/** + * Die Schnellerfassung. + * + * Diese Seite wird im Stehen bedient, mit einer Hand, direkt nach dem + * Besuch. Alles, was nicht in den ersten Sekunden gebraucht wird, ist + * eingeklappt. + */ +export default async function ProtokollNeuSeite({ + searchParams, +}: { + searchParams: Promise<{ kontakt?: string; vorgang?: string; weiter?: string }> +}) { + const parameter = await searchParams + + const benutzer = await erfordereBenutzer('/kontakte/protokoll/neu') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { betreute } = await erfordereZugang(aktive.id, 'KONTAKTE', 'schreiben') + + const [kontakte, vorgaenge] = await Promise.all([ + db.kontakt.findMany({ + where: { betreuteId: betreute.id, ...nichtGeloescht }, + orderBy: [{ name: 'asc' }], + select: { id: true, name: true, organisation: true, rolle: true }, + }), + db.vorgang.findMany({ + where: { betreuteId: betreute.id, erledigtAm: null, ...nichtGeloescht }, + orderBy: [{ aktualisiertAm: 'desc' }], + select: { id: true, titel: true }, + }), + ]) + + // Vorbelegung, wenn aus einem Kontakt oder Vorgang heraus aufgerufen. + const vorbelegt: ProtokollWerte | undefined = + parameter.kontakt || parameter.vorgang + ? { + id: '', + datum: heuteAlsFeldwert(), + art: 'BESUCH', + dauerMinuten: null, + gespraechspartner: null, + mitBetreuter: false, + kontaktId: parameter.kontakt ?? null, + vorgangId: parameter.vorgang ?? null, + inhalt: null, + wuensche: null, + folgeaufgaben: null, + } + : undefined + + return ( +
+ + + ({ + id: kontakt.id, + bezeichnung: `${kontakt.name}${ + kontakt.organisation ? ` – ${kontakt.organisation}` : '' + } (${KONTAKT_ROLLE_BESCHRIFTUNG[kontakt.rolle]})`, + }))} + vorgaenge={vorgaenge.map((vorgang) => ({ id: vorgang.id, bezeichnung: vorgang.titel }))} + protokoll={vorbelegt} + weiter={sichererPfad(parameter.weiter)} + /> + + +
+ ) +} + +/** Heutiges Datum im Format, das ein erwartet. */ +function heuteAlsFeldwert(): string { + const heute = new Date() + const monat = String(heute.getMonth() + 1).padStart(2, '0') + const tag = String(heute.getDate()).padStart(2, '0') + return `${heute.getFullYear()}-${monat}-${tag}` +} + +/** Nur anwendungsinterne Pfade zulassen. */ +function sichererPfad(pfad: string | undefined): string | undefined { + if (!pfad || !pfad.startsWith('/') || pfad.startsWith('//')) return undefined + return pfad +} diff --git a/src/app/(app)/kontakte/protokoll/page.tsx b/src/app/(app)/kontakte/protokoll/page.tsx new file mode 100644 index 0000000..bde8050 --- /dev/null +++ b/src/app/(app)/kontakte/protokoll/page.tsx @@ -0,0 +1,234 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { addYears, isAfter, startOfYear, subMonths } from 'date-fns' +import { MessageSquarePlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Feld } from '@/components/feld' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { AuswertungAnzeige } from '@/components/kontakte/auswertung-anzeige' +import { ProtokollListe, type ProtokollZeile } from '@/components/kontakte/protokoll-liste' +import { darf } from '@/lib/auth/rollen' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { oeffnen } from '@/lib/crypto/feld' +import { db, nichtGeloescht } from '@/lib/db' +import { kontaktstatistik } from '@/lib/kontakte/auswertung' +import { cn } from '@/lib/utils' + +export const metadata: Metadata = { title: 'Kontaktprotokoll' } + +/** + * Alle Kontaktprotokolle, chronologisch, mit Zeitraumfilter - und darueber + * die Auswertung, aus der spaeter der Jahresbericht gespeist wird. + * + * Der Standardzeitraum sind die letzten zwoelf Monate. Wer den Bericht + * schreibt, stellt oben auf das Betreuungsjahr um; die Schnellwahl dafuer + * steht bereit, sobald ein Bestellungsdatum in den Stammdaten steht. + */ +export default async function ProtokollSeite({ + searchParams, +}: { + searchParams: Promise<{ von?: string; bis?: string; hervorgehoben?: string }> +}) { + const parameter = await searchParams + + const benutzer = await erfordereBenutzer('/kontakte/protokoll') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { betreute, zugang } = await erfordereZugang(aktive.id, 'KONTAKTE', 'lesen') + const darfSchreiben = darf(zugang, 'KONTAKTE', 'schreiben') + + const heute = new Date() + const vonVorgabe = subMonths(heute, 12) + + const von = tagAusParameter(parameter.von, 'anfang') ?? tagesAnfang(vonVorgabe) + const bis = tagAusParameter(parameter.bis, 'ende') ?? tagesEnde(heute) + + const [protokolleRoh, auswertung] = await Promise.all([ + db.kontaktprotokoll.findMany({ + where: { betreuteId: betreute.id, datum: { gte: von, lte: bis }, ...nichtGeloescht }, + orderBy: { datum: 'desc' }, + include: { + kontakt: { select: { id: true, name: true } }, + vorgang: { select: { id: true, titel: true } }, + }, + }), + kontaktstatistik(betreute.id, von, bis), + ]) + + const zeilen: ProtokollZeile[] = protokolleRoh.map((eintrag) => { + const offen = oeffnen('Kontaktprotokoll', eintrag) + return { + id: eintrag.id, + datum: eintrag.datum, + art: eintrag.art, + dauerMinuten: eintrag.dauerMinuten, + gespraechspartner: eintrag.gespraechspartner, + mitBetreuter: eintrag.mitBetreuter, + inhalt: offen.inhalt ?? null, + wuensche: offen.wuensche ?? null, + folgeaufgaben: offen.folgeaufgaben ?? null, + kontakt: eintrag.kontakt, + vorgang: eintrag.vorgang, + } + }) + + // Schnellwahlen fuer den Zeitraum. + const schnellwahl: { titel: string; von: Date; bis: Date }[] = [ + { titel: 'Letzte 12 Monate', von: tagesAnfang(subMonths(heute, 12)), bis: tagesEnde(heute) }, + { titel: 'Laufendes Jahr', von: tagesAnfang(startOfYear(heute)), bis: tagesEnde(heute) }, + ] + + if (betreute.bestellungAm) { + const beginn = betreuungsjahrBeginn(betreute.bestellungAm, heute) + schnellwahl.push({ + titel: 'Laufendes Betreuungsjahr', + von: tagesAnfang(beginn), + bis: tagesEnde(heute), + }) + } + + return ( +
+ + +
+ ) +} + +// ----------------------------------------------------------------------------- +// Zeitrechnung +// ----------------------------------------------------------------------------- + +const TAG_MUSTER = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Die Protokolle liegen zur Mittagszeit in UTC. Der Zeitraum wird deshalb + * ebenfalls in UTC aufgespannt - so faellt der Randtag zuverlaessig hinein. + */ +function tagAusParameter(wert: string | undefined, kante: 'anfang' | 'ende'): Date | null { + if (!wert || !TAG_MUSTER.test(wert)) return null + const datum = new Date(`${wert}T${kante === 'anfang' ? '00:00:00.000' : '23:59:59.999'}Z`) + return Number.isNaN(datum.getTime()) ? null : datum +} + +function tagesAnfang(datum: Date): Date { + return new Date( + Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate(), 0, 0, 0, 0), + ) +} + +function tagesEnde(datum: Date): Date { + return new Date( + Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate(), 23, 59, 59, 999), + ) +} + +function alsFeldwert(datum: Date): string { + const monat = String(datum.getUTCMonth() + 1).padStart(2, '0') + const tag = String(datum.getUTCDate()).padStart(2, '0') + return `${datum.getUTCFullYear()}-${monat}-${tag}` +} + +/** + * Das Betreuungsjahr laeuft ab dem Tag der Bestellung, nicht ab dem + * Kalenderjahr. Gesucht ist der letzte Jahrestag, der nicht in der Zukunft + * liegt. + */ +function betreuungsjahrBeginn(bestellungAm: Date, heute: Date): Date { + const start = new Date( + bestellungAm.getUTCFullYear(), + bestellungAm.getUTCMonth(), + bestellungAm.getUTCDate(), + ) + let beginn = start + while (!isAfter(addYears(beginn, 1), heute)) { + beginn = addYears(beginn, 1) + } + return beginn +} diff --git a/src/components/kontakte/auswertung-anzeige.tsx b/src/components/kontakte/auswertung-anzeige.tsx new file mode 100644 index 0000000..883f1d5 --- /dev/null +++ b/src/components/kontakte/auswertung-anzeige.tsx @@ -0,0 +1,120 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { TextKopieren } from '@/components/kontakte/text-kopieren' +import { monatsname } from '@/lib/dashboard/bewertung' +import { dauerAusgeschrieben, type Kontaktstatistik } from '@/lib/kontakte/statistik' +import { formatDatum } from '@/lib/utils' + +/** + * Die Auswertung fuer den Jahresbericht nach § 1863 BGB. + * + * Oben die Zahlen, unten der fertige Absatz zum Uebernehmen. Bewusst kein + * Urteil darueber, ob der Umfang "ausreicht" - das entscheidet das + * Betreuungsgericht, nicht diese Anwendung. + */ +export function AuswertungAnzeige({ + statistik, + textbaustein, +}: { + statistik: Kontaktstatistik + textbaustein: string +}) { + const hoechster = statistik.jeMonat.reduce((max, zeile) => Math.max(max, zeile.gesamt), 0) + + return ( + + + Auswertung für den Jahresbericht +

+ Zeitraum {formatDatum(statistik.von)} bis {formatDatum(statistik.bis)} +

+
+ + +
+ + + + +
+ + {statistik.jeMonat.length > 0 && ( +
+

+ Kontakte je Monat +

+
    + {statistik.jeMonat.map((zeile) => ( +
  • + + {monatsname(zeile.monat)} {zeile.jahr} + + + 0 ? (zeile.gesamt / hoechster) * 100 : 0}%` }} + /> + + + {zeile.gesamt} {zeile.gesamt === 1 ? 'Kontakt' : 'Kontakte'}, davon{' '} + {zeile.persoenlich} persönlich + +
  • + ))} +
+
+ )} + +
+
+

+ Textbaustein für den Bericht +

+ +
+

+ {textbaustein} +

+

+ Der Absatz fasst zusammen, was hier dokumentiert ist. Vor der Übernahme in den Bericht + bitte gegenlesen und um das ergänzen, was sich nicht zählen lässt. +

+
+ + +
+
+ ) +} + +function Zahl({ + beschriftung, + wert, + zusatz, +}: { + beschriftung: string + wert: string + zusatz?: string +}) { + return ( +
+
{beschriftung}
+
{wert}
+ {zusatz &&
{zusatz}
} +
+ ) +} diff --git a/src/components/kontakte/kontakt-karte.tsx b/src/components/kontakte/kontakt-karte.tsx new file mode 100644 index 0000000..e0055a2 --- /dev/null +++ b/src/components/kontakte/kontakt-karte.tsx @@ -0,0 +1,158 @@ +import Link from 'next/link' +import { Mail, MapPin, Phone, Printer, Smartphone } from 'lucide-react' +import type { KontaktRolle } from '@prisma/client' +import { Badge } from '@/components/ui/badge' +import { KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte' +import { cn, formatDatum } from '@/lib/utils' + +/** + * Ein Eintrag im Kontaktverzeichnis. + * + * Telefonnummern sind anklickbar (tel:), E-Mail ebenso (mailto:) - am + * Handy will ich aus dem Verzeichnis heraus waehlen, nicht abtippen. Das + * Aktenzeichen steht gross daneben, weil in jedem Telefonat danach gefragt + * wird. + */ + +export type KontaktListeneintrag = { + id: string + name: string + organisation: string | null + rolle: KontaktRolle + telefon: string | null + mobil: string | null + fax: string | null + email: string | null + strasse: string | null + plz: string | null + ort: string | null + aktenzeichen: string | null + letzterKontaktAm: Date | null + protokolle: number +} + +/** Aus "+49 (921) 604-0" wird "+499216040" - fuer den tel:-Verweis. */ +export function telefonSchema(nummer: string): string { + const gereinigt = nummer.replace(/[^\d+]/g, '') + return gereinigt.startsWith('+') ? `+${gereinigt.slice(1).replace(/\+/g, '')}` : gereinigt +} + +export function KontaktKarte({ + kontakt, + className, +}: { + kontakt: KontaktListeneintrag + className?: string +}) { + const anschrift = [kontakt.strasse, [kontakt.plz, kontakt.ort].filter(Boolean).join(' ')] + .filter(Boolean) + .join(', ') + + return ( +
  • +
    +
    + + {kontakt.name} + + {kontakt.organisation && ( +

    {kontakt.organisation}

    + )} +
    + {kontakt.aktenzeichen && ( + + Az. {kontakt.aktenzeichen} + + )} +
    + +
    + {kontakt.telefon && ( + + {kontakt.telefon} + + )} + {kontakt.mobil && ( + + {kontakt.mobil} + + )} + {kontakt.email && ( + + {kontakt.email} + + )} + {kontakt.fax && ( + + + )} + {anschrift && ( + + + )} +
    + +

    + {kontakt.protokolle === 0 + ? 'Noch kein Gespräch festgehalten' + : `${kontakt.protokolle} ${ + kontakt.protokolle === 1 ? 'Eintrag' : 'Einträge' + } im Kontaktprotokoll`} + {kontakt.letzterKontaktAm && `, zuletzt am ${formatDatum(kontakt.letzterKontaktAm)}`} + {' · '} + + Gespräch festhalten + +

    + Rolle: {KONTAKT_ROLLE_BESCHRIFTUNG[kontakt.rolle]} +
  • + ) +} + +function Verweis({ + href, + icon: Icon, + beschriftung, + children, +}: { + href: string + icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }> + beschriftung: string + children: React.ReactNode +}) { + return ( + + + {children} + + ) +} diff --git a/src/components/kontakte/papierkorb-knopf.tsx b/src/components/kontakte/papierkorb-knopf.tsx new file mode 100644 index 0000000..13fd013 --- /dev/null +++ b/src/components/kontakte/papierkorb-knopf.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useFormStatus } from 'react-dom' +import { Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' + +/** + * Loeschen ist hier immer ein Papierkorb: "geloeschtAm" wird gesetzt, der + * Datensatz bleibt 30 Tage lesbar. Trotzdem wird nachgefragt - versehentlich + * verschwundene Protokolle fehlen im Jahresbericht. + */ +export function PapierkorbKnopf({ + aktion, + id, + betreuteId, + frage, + beschriftung = 'In den Papierkorb', +}: { + aktion: (formular: FormData) => Promise + id: string + betreuteId: string + frage: string + beschriftung?: string +}) { + return ( +
    { + if (!window.confirm(frage)) ereignis.preventDefault() + }} + > + + + + + ) +} + +function Knopf({ beschriftung }: { beschriftung: string }) { + const { pending } = useFormStatus() + return ( + + ) +} diff --git a/src/components/kontakte/protokoll-erfassung.tsx b/src/components/kontakte/protokoll-erfassung.tsx index dce2391..ef07d33 100644 --- a/src/components/kontakte/protokoll-erfassung.tsx +++ b/src/components/kontakte/protokoll-erfassung.tsx @@ -92,7 +92,7 @@ export function ProtokollErfassung({ const vorgabeMitBetreuter = zustand.werte !== undefined ? zustand.werte.mitBetreuter === 'on' - : (protokoll?.mitBetreuter ?? true) + : (protokoll?.mitBetreuter ?? false) const mehrOffen = Boolean( protokoll?.gespraechspartner || diff --git a/src/components/kontakte/protokoll-liste.tsx b/src/components/kontakte/protokoll-liste.tsx new file mode 100644 index 0000000..0e2296e --- /dev/null +++ b/src/components/kontakte/protokoll-liste.tsx @@ -0,0 +1,118 @@ +import Link from 'next/link' +import { Clock, User } from 'lucide-react' +import type { KontaktArt } from '@prisma/client' +import { Badge } from '@/components/ui/badge' +import { KONTAKT_ART_BESCHRIFTUNG } from '@/lib/validierung/kontakte' +import { dauerAusgeschrieben } from '@/lib/kontakte/statistik' +import { cn, formatDatum } from '@/lib/utils' + +/** + * Chronologische Liste der Kontaktprotokolle. + * + * Die Freitexte kommen bereits entschluesselt herein - entschluesselt wird + * auf dem Server, in der Seite. + */ + +export type ProtokollZeile = { + id: string + datum: Date + art: KontaktArt + dauerMinuten: number | null + gespraechspartner: string | null + mitBetreuter: boolean + inhalt: string | null + wuensche: string | null + folgeaufgaben: string | null + kontakt: { id: string; name: string } | null + vorgang: { id: string; titel: string } | null +} + +export function ProtokollListe({ + zeilen, + hervorgehoben, + ohneKontaktspalte, +}: { + zeilen: ProtokollZeile[] + /** Gerade gespeicherter Eintrag - wird kurz hervorgehoben. */ + hervorgehoben?: string + /** Auf der Kontaktseite ist die Kontaktangabe redundant. */ + ohneKontaktspalte?: boolean +}) { + return ( +
      + {zeilen.map((zeile) => ( +
    1. +
      + {formatDatum(zeile.datum)} + {KONTAKT_ART_BESCHRIFTUNG[zeile.art]} + {zeile.mitBetreuter && ( + + + )} + {zeile.dauerMinuten !== null && ( + + + )} +
      + + {(zeile.gespraechspartner || (!ohneKontaktspalte && zeile.kontakt) || zeile.vorgang) && ( +

      + {zeile.gespraechspartner && <>mit {zeile.gespraechspartner}} + {!ohneKontaktspalte && zeile.kontakt && ( + <> + {zeile.gespraechspartner ? ' · ' : ''} + + {zeile.kontakt.name} + + + )} + {zeile.vorgang && ( + <> + {zeile.gespraechspartner || zeile.kontakt ? ' · ' : ''} + + {zeile.vorgang.titel} + + + )} +

      + )} + + {zeile.inhalt &&

      {zeile.inhalt}

      } + + {zeile.wuensche && ( +
      +

      Geäußerte Wünsche

      +

      {zeile.wuensche}

      +
      + )} + + {zeile.folgeaufgaben && ( +
      +

      Daraus folgt

      +

      {zeile.folgeaufgaben}

      +
      + )} + +

      + + Eintrag bearbeiten + +

      +
    2. + ))} +
    + ) +} diff --git a/src/components/kontakte/text-kopieren.tsx b/src/components/kontakte/text-kopieren.tsx new file mode 100644 index 0000000..cce63f9 --- /dev/null +++ b/src/components/kontakte/text-kopieren.tsx @@ -0,0 +1,37 @@ +'use client' + +import * as React from 'react' +import { Check, Copy } from 'lucide-react' +import { Button } from '@/components/ui/button' + +/** + * Kopiert den Textbaustein in die Zwischenablage - von dort wandert er in + * den Jahresbericht. Klappt das nicht (aelterer Browser, kein sicherer + * Kontext), wird der Text stattdessen markiert; abtippen muss ihn niemand. + */ +export function TextKopieren({ text, ziel }: { text: string; ziel: string }) { + const [kopiert, setKopiert] = React.useState(false) + + async function kopieren() { + try { + await navigator.clipboard.writeText(text) + setKopiert(true) + window.setTimeout(() => setKopiert(false), 3000) + } catch { + const knoten = document.getElementById(ziel) + if (!knoten) return + const auswahl = window.getSelection() + const bereich = document.createRange() + bereich.selectNodeContents(knoten) + auswahl?.removeAllRanges() + auswahl?.addRange(bereich) + } + } + + return ( + + ) +} diff --git a/src/lib/kontakte/statistik.ts b/src/lib/kontakte/statistik.ts index 6e94e6d..1eabe7e 100644 --- a/src/lib/kontakte/statistik.ts +++ b/src/lib/kontakte/statistik.ts @@ -1,4 +1,3 @@ -import { differenceInCalendarMonths } from 'date-fns' import type { KontaktArt } from '@prisma/client' /** @@ -67,6 +66,17 @@ const ALLE_ARTEN: KontaktArt[] = [ 'VORSPRACHE', ] +/** + * Angefangene Kalendermonate zwischen zwei Zeitpunkten, in UTC gerechnet. + * Der Rest dieser Datei rechnet ebenfalls in UTC - sonst wandert ein + * Zeitraumende wie "31.12., 23:59 UTC" in Mitteleuropa in den Januar. + */ +function monateZwischen(von: Date, bis: Date): number { + const monate = + (bis.getUTCFullYear() - von.getUTCFullYear()) * 12 + (bis.getUTCMonth() - von.getUTCMonth()) + return Math.max(1, monate + 1) +} + function tagesschluessel(datum: Date): string { return `${datum.getUTCFullYear()}-${datum.getUTCMonth()}-${datum.getUTCDate()}` } @@ -133,7 +143,7 @@ export function statistikBerechnen( return { von, bis, - monateImZeitraum: Math.max(1, differenceInCalendarMonths(bis, von) + 1), + monateImZeitraum: monateZwischen(von, bis), gesamt, persoenlich, persoenlicheTage: persoenlicheTage.size, @@ -159,7 +169,7 @@ export type Textkontext = { akkusativ: string /** Fertige Praepositionalphrase: "zur Betreuten", "zum Betreuten". */ kontaktZu: string - /** "das Pflegeheim Sonnenhof" - wird zu "im Pflegeheim Sonnenhof". */ + /** "Pflegeheim Sonnenhof" - wird zu "im Pflegeheim Sonnenhof". */ heimName: string | null } @@ -253,7 +263,7 @@ export function textbausteinErzeugen( // Satz 2: Besuche, Telefonate, Schriftverkehr in Zahlen. const teile: string[] = [] if (statistik.besuchstage > 0) { - const ort = kontext.heimName ? ` in ${kontext.heimName}` : '' + const ort = kontext.heimName ? ` im ${kontext.heimName}` : '' teile.push( `habe ${kontext.akkusativ} an ${mehrzahl( statistik.besuchstage, diff --git a/src/lib/validierung/kontakte.ts b/src/lib/validierung/kontakte.ts index d10f3b7..f74c1c6 100644 --- a/src/lib/validierung/kontakte.ts +++ b/src/lib/validierung/kontakte.ts @@ -53,7 +53,9 @@ const pflichtTag = z message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.', }) .transform((wert) => new Date(`${wert}T12:00:00.000Z`)) - .refine((datum) => !Number.isNaN(datum.getTime()), { + // Nach dem Umwandeln noch einmal pruefen: "2026-02-31" passt zwar auf + // das Muster, ergibt aber kein Datum. + .refine((datum) => datum instanceof Date && !Number.isNaN(datum.getTime()), { message: 'Dieses Datum gibt es nicht.', }) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts new file mode 100644 index 0000000..5310bd1 --- /dev/null +++ b/tests/dashboard.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from 'vitest' +import { + fristLage, + fristStufe, + fristenEinordnen, + heimkostenlageBerechnen, + nachforderungenAuswerten, + tagAusDatenbank, + vermoegenBerechnen, + verfallwarnung, + wartendeEinordnen, + WARTEZEIT_DRINGEND_TAGE, + type FristRoh, + type HeimkostenmonatRoh, + type NachforderungRoh, + type PostenRoh, + type WartendRoh, +} from '@/lib/dashboard/bewertung' + +/** + * Das Dashboard entscheidet, was heute dringend ist. Diese Tests + * beschreiben, was fachlich gelten soll - nicht, welche Funktion gerufen + * wird. + */ + +/** Mittwoch, 19.08.2026. Die Woche endet am Sonntag, dem 23.08. */ +const HEUTE = new Date(2026, 7, 19) + +/** Ein @db.Date kommt als UTC-Mitternacht aus der Datenbank. */ +function dbTag(text: string): Date { + return new Date(`${text}T00:00:00.000Z`) +} + +function frist(teil: Partial & { datum: Date }): FristRoh { + return { + id: 'f1', + titel: 'Irgendeine Frist', + typ: 'GESETZLICH', + rechtsgrundlage: null, + ausschlussfrist: false, + vorgangId: null, + ...teil, + } +} + +describe('Fristen-Ampel', () => { + it('meldet ein Datum in der Vergangenheit als überfällig', () => { + expect(fristStufe(dbTag('2026-08-10'), HEUTE)).toBe('rot') + }) + + it('färbt orange, was bis zum Sonntag dieser Woche fällig wird', () => { + expect(fristStufe(dbTag('2026-08-19'), HEUTE)).toBe('orange') + expect(fristStufe(dbTag('2026-08-23'), HEUTE)).toBe('orange') + }) + + it('färbt gelb, was noch in diesem Monat fällig wird', () => { + expect(fristStufe(dbTag('2026-08-24'), HEUTE)).toBe('gelb') + expect(fristStufe(dbTag('2026-08-31'), HEUTE)).toBe('gelb') + }) + + it('lässt alles Spätere grün', () => { + expect(fristStufe(dbTag('2026-09-01'), HEUTE)).toBe('gruen') + }) + + it('stellt überfällige Fristen nach oben und nennt die Tage', () => { + const daten = fristenEinordnen( + [ + frist({ id: 'kommend', datum: dbTag('2026-08-28') }), + frist({ id: 'alt', datum: dbTag('2026-07-30') }), + frist({ id: 'aeltest', datum: dbTag('2026-06-01') }), + ], + HEUTE, + ) + + expect(daten.ueberfaellig.map((e) => e.id)).toEqual(['aeltest', 'alt']) + expect(daten.ueberfaellig[0]?.tage).toBe(-79) + expect(daten.diesenMonat.map((e) => e.id)).toEqual(['kommend']) + }) + + it('zählt Fristen jenseits des Monats gesondert, statt sie zu verschweigen', () => { + const daten = fristenEinordnen( + [frist({ id: 'spaet', datum: dbTag('2027-03-01') })], + HEUTE, + ) + expect(daten.weitereOffen).toBe(1) + expect(daten.gesamtOffen).toBe(1) + expect(daten.ueberfaellig).toHaveLength(0) + }) + + it('hebt Ausschlussfristen unabhängig vom Fälligkeitstopf hervor', () => { + const daten = fristenEinordnen( + [ + frist({ id: 'normal', datum: dbTag('2026-08-20') }), + frist({ + id: 'pauschale', + titel: 'Aufwandspauschale 2026', + datum: dbTag('2026-12-31'), + ausschlussfrist: true, + rechtsgrundlage: '§ 1878 BGB', + }), + ], + HEUTE, + ) + + expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['pauschale']) + }) + + it('warnt bei einer Ausschlussfrist ausdrücklich vor dem ersatzlosen Verfall', () => { + const text = verfallwarnung({ tage: 12, rechtsgrundlage: '§ 1878 BGB' }) + expect(text).toContain('§ 1878 BGB') + expect(text).toContain('ersatzlos weg') + }) + + it('sagt bei einer abgelaufenen Ausschlussfrist, wie lange sie schon abgelaufen ist', () => { + const text = verfallwarnung({ tage: -3, rechtsgrundlage: null }) + expect(text).toContain('seit 3 Tagen abgelaufen') + expect(text).toContain('ersatzlos weg') + }) + + it('schreibt die Lage in Worten, damit Farbe nie die einzige Information ist', () => { + expect(fristLage(-5)).toBe('seit 5 Tagen überfällig') + expect(fristLage(-1)).toBe('seit gestern überfällig') + expect(fristLage(0)).toBe('heute fällig') + expect(fristLage(1)).toBe('morgen fällig') + expect(fristLage(9)).toBe('in 9 Tagen fällig') + }) + + it('verschiebt ein Datum ohne Uhrzeit nicht über die Zeitzone hinweg', () => { + const tag = tagAusDatenbank(dbTag('2026-08-31')) + expect(tag.getDate()).toBe(31) + expect(tag.getMonth()).toBe(7) + }) +}) + +describe('Wartet auf andere', () => { + function vorgang(teil: Partial): WartendRoh { + return { + id: 'v1', + titel: 'Antrag Hilfe zur Pflege', + kategorie: 'SOZIALHILFE', + gegenstelle: 'Bezirk Oberfranken', + aktenzeichen: null, + naechsterSchritt: null, + letzterKontaktAm: null, + erstelltAm: new Date(2026, 7, 1), + ...teil, + } + } + + it('sortiert die längste Wartezeit nach oben', () => { + const daten = wartendeEinordnen( + [ + vorgang({ id: 'kurz', letzterKontaktAm: dbTag('2026-08-15') }), + vorgang({ id: 'lang', letzterKontaktAm: dbTag('2026-06-01') }), + ], + HEUTE, + ) + expect(daten.eintraege.map((e) => e.id)).toEqual(['lang', 'kurz']) + expect(daten.eintraege[0]?.tageOhneRueckmeldung).toBe(79) + }) + + it(`hebt ab ${WARTEZEIT_DRINGEND_TAGE} Tagen ohne Rückmeldung hervor`, () => { + const grenze = wartendeEinordnen( + [vorgang({ letzterKontaktAm: dbTag('2026-07-29') })], // genau 21 Tage + HEUTE, + ) + expect(grenze.eintraege[0]?.tageOhneRueckmeldung).toBe(WARTEZEIT_DRINGEND_TAGE) + expect(grenze.eintraege[0]?.dringend).toBe(true) + expect(grenze.dringende).toBe(1) + + const knapp = wartendeEinordnen([vorgang({ letzterKontaktAm: dbTag('2026-07-30') })], HEUTE) + expect(knapp.eintraege[0]?.dringend).toBe(false) + }) + + it('rechnet ab dem Anlegen, wenn nie ein Kontakt vermerkt wurde', () => { + const daten = wartendeEinordnen([vorgang({ letzterKontaktAm: null })], HEUTE) + expect(daten.eintraege[0]?.tageOhneRueckmeldung).toBe(18) + expect(daten.eintraege[0]?.letzterKontaktAm).toBeNull() + }) +}) + +describe('Nachforderungen des Trägers', () => { + function zeile(teil: Partial): NachforderungRoh { + return { + id: 'n1', + bezeichnung: 'Kontoauszüge der letzten 12 Monate', + status: 'OFFEN', + fristBis: null, + reihenfolge: 0, + antragId: 'a1', + antragTraeger: 'Bezirk Oberfranken', + antragAktenzeichen: 'HzP 12345', + ...teil, + } + } + + it('zählt übersandte, anerkannte und begründet fehlende Unterlagen als erledigt', () => { + const daten = nachforderungenAuswerten( + [ + zeile({ id: '1', status: 'UEBERSANDT' }), + zeile({ id: '2', status: 'ANERKANNT' }), + zeile({ id: '3', status: 'NICHT_VORHANDEN' }), + zeile({ id: '4', status: 'OFFEN' }), + zeile({ id: '5', status: 'IN_BESCHAFFUNG' }), + ], + HEUTE, + ) + + expect(daten.gesamt).toBe(5) + expect(daten.erledigt).toBe(3) + expect(daten.offen.map((o) => o.id)).toEqual(['4', '5']) + }) + + it('reiht die dringendste Frist zuoberst und meldet abgelaufene gesondert', () => { + const daten = nachforderungenAuswerten( + [ + zeile({ id: 'ohne', fristBis: null }), + zeile({ id: 'spaet', fristBis: dbTag('2026-09-30') }), + zeile({ id: 'abgelaufen', fristBis: dbTag('2026-08-10') }), + ], + HEUTE, + ) + + expect(daten.offen.map((o) => o.id)).toEqual(['abgelaufen', 'spaet', 'ohne']) + expect(daten.offen[0]?.tageBisFrist).toBe(-9) + expect(daten.ueberfaellig).toBe(1) + }) + + it('nimmt Träger und Aktenzeichen aus dem Antrag mit', () => { + const daten = nachforderungenAuswerten([zeile({})], HEUTE) + expect(daten.traeger).toBe('Bezirk Oberfranken') + expect(daten.aktenzeichen).toBe('HzP 12345') + expect(daten.antragId).toBe('a1') + }) + + it('kommt mit einer leeren Liste zurecht', () => { + const daten = nachforderungenAuswerten([], HEUTE) + expect(daten.gesamt).toBe(0) + expect(daten.traeger).toBeNull() + }) +}) + +describe('Vermögen zum letzten Stichtag', () => { + function posten(teil: Partial): PostenRoh { + return { id: 'p1', art: 'KONTO', wertstaende: [], ...teil } + } + + it('nimmt je Posten den jüngsten Wertstand und zieht Verbindlichkeiten ab', () => { + const lage = vermoegenBerechnen([ + posten({ + id: 'giro', + art: 'KONTO', + wertstaende: [ + { stichtag: dbTag('2026-01-01'), wertCent: 100_000n }, + { stichtag: dbTag('2026-07-01'), wertCent: 250_000n }, + ], + }), + posten({ + id: 'lebensversicherung', + art: 'VERSICHERUNG', + wertstaende: [{ stichtag: dbTag('2026-06-01'), wertCent: 1_248_000n }], + }), + posten({ + id: 'heimrechnung', + art: 'VERBINDLICHKEIT', + wertstaende: [{ stichtag: dbTag('2026-07-01'), wertCent: 340_000n }], + }), + ]) + + expect(lage.aktivaCent).toBe(1_498_000n) + expect(lage.verbindlichkeitenCent).toBe(340_000n) + expect(lage.nettoCent).toBe(1_158_000n) + }) + + it('weist den jüngsten tatsächlich verwendeten Stichtag aus', () => { + const lage = vermoegenBerechnen([ + posten({ + wertstaende: [ + { stichtag: dbTag('2026-03-15'), wertCent: 1n }, + { stichtag: dbTag('2026-05-20'), wertCent: 2n }, + ], + }), + ]) + expect(lage.stichtag?.getDate()).toBe(20) + expect(lage.stichtag?.getMonth()).toBe(4) + }) + + it('zählt Posten ohne Wert gesondert, statt sie stillschweigend als 0 zu führen', () => { + const lage = vermoegenBerechnen([ + posten({ id: 'a', wertstaende: [{ stichtag: dbTag('2026-05-01'), wertCent: 500n }] }), + posten({ id: 'b', wertstaende: [] }), + posten({ id: 'c', wertstaende: [] }), + ]) + + expect(lage.postenGesamt).toBe(3) + expect(lage.postenOhneWert).toBe(2) + expect(lage.nettoCent).toBe(500n) + }) + + it('liefert bei einer frischen Installation keinen Stichtag', () => { + const lage = vermoegenBerechnen([]) + expect(lage.stichtag).toBeNull() + expect(lage.nettoCent).toBe(0n) + }) +}) + +describe('Heimkosten und Deckungslücke', () => { + function monat(teil: Partial): HeimkostenmonatRoh { + return { + jahr: 2026, + monat: 7, + pflegeEigenanteilCent: 0n, + unterkunftVerpflegungCent: 0n, + investitionskostenCent: 0n, + ausbildungsumlageCent: 0n, + zusatzleistungenCent: 0n, + pflegekasseCent: 0n, + leistungszuschlagCent: 0n, + renteCent: 0n, + sonstigeEinkuenfteCent: 0n, + sozialhilfeCent: 0n, + eigenmittelCent: 0n, + ...teil, + } + } + + it('stellt die Kostenseite der Deckungsseite gegenüber', () => { + const lage = heimkostenlageBerechnen( + monat({ + pflegeEigenanteilCent: 130_000n, + unterkunftVerpflegungCent: 95_000n, + investitionskostenCent: 45_000n, + ausbildungsumlageCent: 5_000n, + renteCent: 120_000n, + sozialhilfeCent: 100_000n, + }), + ) + + expect(lage.gesamtkostenCent).toBe(275_000n) + expect(lage.deckungCent).toBe(220_000n) + expect(lage.deckungsluecke).toBe(55_000n) + }) + + it('weist eine Überdeckung als negative Lücke aus', () => { + const lage = heimkostenlageBerechnen( + monat({ unterkunftVerpflegungCent: 100_000n, renteCent: 150_000n }), + ) + expect(lage.deckungsluecke).toBe(-50_000n) + }) + + it('rechnet in Cent und nicht in Fließkomma', () => { + const lage = heimkostenlageBerechnen( + monat({ pflegeEigenanteilCent: 1n, renteCent: 0n }), + ) + expect(typeof lage.deckungsluecke).toBe('bigint') + expect(lage.deckungsluecke).toBe(1n) + }) +}) diff --git a/tests/kontakte.test.ts b/tests/kontakte.test.ts new file mode 100644 index 0000000..90e1f57 --- /dev/null +++ b/tests/kontakte.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest' +import { + KontaktSchema, + KontaktprotokollSchema, + kontaktFormularLesen, + protokollFormularLesen, + tagFuerFormular, +} from '@/lib/validierung/kontakte' +import { + dauerAusgeschrieben, + statistikBerechnen, + textbausteinErzeugen, + textkontextAusAnrede, + type ProtokollFuerStatistik, +} from '@/lib/kontakte/statistik' + +/** + * Kontakte und Kontaktprotokoll. Die Protokolle sind die Grundlage des + * Jahresberichts nach § 1863 BGB - was hier falsch gezaehlt wird, steht + * spaeter falsch im Bericht ans Betreuungsgericht. + */ + +function formular(werte: Record): FormData { + const daten = new FormData() + for (const [name, wert] of Object.entries(werte)) daten.set(name, wert) + return daten +} + +/** Ein Protokollzeitstempel liegt zur Mittagszeit in UTC. */ +function tag(text: string): Date { + return new Date(`${text}T12:00:00.000Z`) +} + +describe('Kontakt erfassen', () => { + it('braucht einen Namen und eine Rolle, alles andere ist freiwillig', () => { + const ergebnis = KontaktSchema.safeParse({ name: 'Frau Krämer', rolle: 'RECHTSPFLEGER' }) + expect(ergebnis.success).toBe(true) + if (ergebnis.success) { + expect(ergebnis.data.name).toBe('Frau Krämer') + expect(ergebnis.data.telefon).toBeNull() + } + }) + + it('weist einen fehlenden Namen mit einer verständlichen Meldung ab', () => { + const ergebnis = KontaktSchema.safeParse({ name: ' ', rolle: 'HEIMLEITUNG' }) + expect(ergebnis.success).toBe(false) + if (!ergebnis.success) { + expect(ergebnis.error.flatten().fieldErrors.name?.[0]).toContain('Namen angeben') + } + }) + + it('macht aus leeren Feldern NULL, nicht aus Versehen einen leeren Text', () => { + const ergebnis = KontaktSchema.parse({ + name: 'Amtsgericht Bayreuth', + rolle: 'RECHTSPFLEGER', + organisation: '', + telefon: '', + aktenzeichen: '', + }) + expect(ergebnis.organisation).toBeNull() + expect(ergebnis.telefon).toBeNull() + expect(ergebnis.aktenzeichen).toBeNull() + }) + + it('lässt das E-Mail-Feld leer, akzeptiert aber keine kaputte Adresse', () => { + expect(KontaktSchema.safeParse({ name: 'Dr. Weber', rolle: 'HAUSARZT', email: '' }).success).toBe( + true, + ) + + const kaputt = KontaktSchema.safeParse({ + name: 'Dr. Weber', + rolle: 'HAUSARZT', + email: 'weber(at)praxis.de', + }) + expect(kaputt.success).toBe(false) + }) + + it('schreibt E-Mail-Adressen klein und schneidet Leerzeichen ab', () => { + const ergebnis = KontaktSchema.parse({ + name: 'Bezirk Oberfranken', + rolle: 'SACHBEARBEITER_BEZIRK', + email: ' Sachbearbeitung@Bezirk-Oberfranken.DE ', + }) + expect(ergebnis.email).toBe('sachbearbeitung@bezirk-oberfranken.de') + }) + + it('nimmt eine unbekannte Rolle nicht an', () => { + expect(KontaktSchema.safeParse({ name: 'X', rolle: 'HAUSMEISTER' }).success).toBe(false) + }) + + it('liest ein Formular vollständig ein', () => { + const roh = kontaktFormularLesen( + formular({ name: 'Frau Krämer', rolle: 'RECHTSPFLEGER', telefon: '0921 504-0' }), + ) + const ergebnis = KontaktSchema.parse(roh) + expect(ergebnis.telefon).toBe('0921 504-0') + }) +}) + +describe('Kontaktprotokoll erfassen', () => { + const grundgeruest = { datum: '2026-08-19', art: 'BESUCH' } + + it('kommt mit Datum und Art aus – das ist die Schnellerfassung', () => { + const ergebnis = KontaktprotokollSchema.safeParse(grundgeruest) + expect(ergebnis.success).toBe(true) + if (ergebnis.success) { + expect(ergebnis.data.dauerMinuten).toBeNull() + expect(ergebnis.data.mitBetreuter).toBe(false) + expect(ergebnis.data.inhalt).toBeNull() + } + }) + + it('legt das Datum auf die Mittagszeit, damit keine Zeitzone den Tag kippt', () => { + const ergebnis = KontaktprotokollSchema.parse(grundgeruest) + expect(ergebnis.datum.toISOString()).toBe('2026-08-19T12:00:00.000Z') + expect(tagFuerFormular(ergebnis.datum)).toBe('2026-08-19') + }) + + it('besteht auf einem Datum', () => { + const ergebnis = KontaktprotokollSchema.safeParse({ datum: '', art: 'TELEFONAT' }) + expect(ergebnis.success).toBe(false) + if (!ergebnis.success) { + expect(ergebnis.error.flatten().fieldErrors.datum?.[0]).toContain('Datum') + } + }) + + it('nimmt die Dauer als Zahl an und lässt sie auch weg', () => { + expect(KontaktprotokollSchema.parse({ ...grundgeruest, dauerMinuten: '45' }).dauerMinuten).toBe( + 45, + ) + expect(KontaktprotokollSchema.parse({ ...grundgeruest, dauerMinuten: '' }).dauerMinuten).toBeNull() + }) + + it('weist eine unsinnige Dauer ab', () => { + expect( + KontaktprotokollSchema.safeParse({ ...grundgeruest, dauerMinuten: '-10' }).success, + ).toBe(false) + expect( + KontaktprotokollSchema.safeParse({ ...grundgeruest, dauerMinuten: '2000' }).success, + ).toBe(false) + expect( + KontaktprotokollSchema.safeParse({ ...grundgeruest, dauerMinuten: 'lang' }).success, + ).toBe(false) + }) + + it('übersetzt das Häkchen für den persönlichen Kontakt in einen Wahrheitswert', () => { + const angehakt = protokollFormularLesen( + formular({ datum: '2026-08-19', art: 'BESUCH', mitBetreuter: 'on' }), + ) + expect(KontaktprotokollSchema.parse(angehakt).mitBetreuter).toBe(true) + + const leer = protokollFormularLesen(formular({ datum: '2026-08-19', art: 'BESUCH' })) + expect(KontaktprotokollSchema.parse(leer).mitBetreuter).toBe(false) + }) + + it('behandelt eine nicht gewählte Verknüpfung als "keine"', () => { + const roh = protokollFormularLesen( + formular({ datum: '2026-08-19', art: 'BESUCH', kontaktId: '', vorgangId: '' }), + ) + const ergebnis = KontaktprotokollSchema.parse(roh) + expect(ergebnis.kontaktId).toBeNull() + expect(ergebnis.vorgangId).toBeNull() + }) + + it('nimmt eine Kontaktart, die es nicht gibt, nicht an', () => { + expect(KontaktprotokollSchema.safeParse({ datum: '2026-08-19', art: 'TAUBENPOST' }).success).toBe( + false, + ) + }) +}) + +describe('Auswertung für den Jahresbericht', () => { + const VON = new Date('2026-01-01T00:00:00.000Z') + const BIS = new Date('2026-12-31T23:59:59.999Z') + + function eintrag(teil: Partial & { datum: Date }): ProtokollFuerStatistik { + return { art: 'BESUCH', dauerMinuten: null, mitBetreuter: false, ...teil } + } + + it('zählt die Kontakte je Monat und darunter die persönlichen', () => { + const statistik = statistikBerechnen( + [ + eintrag({ datum: tag('2026-01-12'), mitBetreuter: true }), + eintrag({ datum: tag('2026-01-26'), art: 'TELEFONAT' }), + eintrag({ datum: tag('2026-02-03'), mitBetreuter: true }), + ], + VON, + BIS, + ) + + expect(statistik.jeMonat).toEqual([ + { jahr: 2026, monat: 1, gesamt: 2, persoenlich: 1 }, + { jahr: 2026, monat: 2, gesamt: 1, persoenlich: 1 }, + ]) + expect(statistik.gesamt).toBe(3) + expect(statistik.persoenlich).toBe(2) + }) + + it('zählt zwei Besuche am selben Tag als einen Besuchstag', () => { + const statistik = statistikBerechnen( + [ + eintrag({ datum: tag('2026-03-04'), mitBetreuter: true }), + eintrag({ datum: tag('2026-03-04'), mitBetreuter: true }), + eintrag({ datum: tag('2026-03-05'), mitBetreuter: true }), + ], + VON, + BIS, + ) + expect(statistik.persoenlich).toBe(3) + expect(statistik.besuchstage).toBe(2) + }) + + it('rechnet einen Anruf beim Bezirk nicht als persönlichen Kontakt zur Betreuten', () => { + const statistik = statistikBerechnen( + [eintrag({ datum: tag('2026-04-02'), art: 'TELEFONAT', mitBetreuter: false })], + VON, + BIS, + ) + expect(statistik.telefonate).toBe(1) + expect(statistik.persoenlich).toBe(0) + expect(statistik.besuchstage).toBe(0) + }) + + it('summiert nur die tatsächlich erfassten Dauern', () => { + const statistik = statistikBerechnen( + [ + eintrag({ datum: tag('2026-05-02'), dauerMinuten: 45 }), + eintrag({ datum: tag('2026-05-09'), dauerMinuten: 90 }), + eintrag({ datum: tag('2026-05-16'), dauerMinuten: null }), + ], + VON, + BIS, + ) + expect(statistik.gesamtdauerMinuten).toBe(135) + expect(statistik.mitDauerErfasst).toBe(2) + expect(statistik.gesamt).toBe(3) + }) + + it('zählt die Monate des Berichtszeitraums mit', () => { + expect(statistikBerechnen([], VON, BIS).monateImZeitraum).toBe(12) + expect( + statistikBerechnen([], new Date('2026-03-01T00:00:00Z'), new Date('2026-05-31T00:00:00Z')) + .monateImZeitraum, + ).toBe(3) + }) + + it('schreibt einen Textbaustein in ganzen Sätzen mit Zeitraum und Zahlen', () => { + const protokolle: ProtokollFuerStatistik[] = [] + // Ein Besuch je Monat, dazu zwei Telefonate. + for (let monat = 1; monat <= 12; monat += 1) { + const mm = String(monat).padStart(2, '0') + protokolle.push( + eintrag({ datum: tag(`2026-${mm}-10`), mitBetreuter: true, dauerMinuten: 60 }), + ) + } + protokolle.push(eintrag({ datum: tag('2026-03-20'), art: 'TELEFONAT' })) + protokolle.push(eintrag({ datum: tag('2026-09-08'), art: 'TELEFONAT' })) + + const statistik = statistikBerechnen(protokolle, VON, BIS) + const text = textbausteinErzeugen( + statistik, + textkontextAusAnrede('Frau', 'Pflegeheim Sonnenhof'), + ) + + expect(text).toContain('Im Berichtszeitraum vom 01.01.2026 bis 31.12.2026') + expect(text).toContain('regelmäßiger persönlicher Kontakt zur Betreuten') + expect(text).toContain('an 12 Tagen persönlich im Pflegeheim Sonnenhof besucht') + expect(text).toContain('2 Telefonate') + expect(text).toContain('12 Stunden') + expect(text).toContain('12 von 12 Monaten') + }) + + it('nennt es nicht "regelmäßig", wenn der Kontakt nur sporadisch war', () => { + const statistik = statistikBerechnen( + [ + eintrag({ datum: tag('2026-02-10'), mitBetreuter: true }), + eintrag({ datum: tag('2026-08-14'), mitBetreuter: true }), + ], + VON, + BIS, + ) + const text = textbausteinErzeugen(statistik) + expect(text).toContain('bestand persönlicher Kontakt') + expect(text).not.toContain('regelmäßiger') + }) + + it('beschönigt nichts, wenn kein persönlicher Kontakt dokumentiert ist', () => { + const statistik = statistikBerechnen( + [eintrag({ datum: tag('2026-06-02'), art: 'BRIEF' })], + VON, + BIS, + ) + const text = textbausteinErzeugen(statistik, textkontextAusAnrede('Herr', null)) + expect(text).toContain('kein persönlicher Kontakt zum Betreuten dokumentiert') + }) + + it('sagt bei einem leeren Zeitraum, dass nachzutragen ist', () => { + const text = textbausteinErzeugen(statistikBerechnen([], VON, BIS)) + expect(text).toContain('kein Kontakt dokumentiert') + expect(text).toContain('nachzutragen') + }) + + it('schreibt Dauern in Stunden und Minuten aus', () => { + expect(dauerAusgeschrieben(45)).toBe('45 Minuten') + expect(dauerAusgeschrieben(60)).toBe('1 Stunde') + expect(dauerAusgeschrieben(135)).toBe('2 Stunden 15 Minuten') + expect(dauerAusgeschrieben(0)).toBe('0 Minuten') + }) + + it('wählt die Anrede passend zur betreuten Person', () => { + expect(textkontextAusAnrede('Frau', null).kontaktZu).toBe('zur Betreuten') + expect(textkontextAusAnrede('Herr', null).akkusativ).toBe('den Betreuten') + expect(textkontextAusAnrede(null, null).kontaktZu).toBe('zur betreuten Person') + }) +})