feat: Dashboard Heute, Kontakte und Kontaktprotokoll

Das Dashboard beantwortet die Frage, was heute ansteht - nicht, welche
Daten es gibt. Jede Kachel endet in einer Handlung: Fristen-Ampel,
wartet auf andere (mit Tagen seit dem letzten Kontakt), offene
Nachforderungen des Bezirks mit Fortschrittsbalken, ungepruefter
Posteingang, unverbuchte Belege, Kennzahlen.

Zwoelf Abfragen in einem Promise.all, kein N+1. Cent-Betraege werden
beim Verlassen der Serverseite gewandelt.

Kacheln zu Modulen, die ein Mitleser nicht freigegeben bekommen hat,
entfallen ganz - sie zeigen nicht "0", denn das waere eine Aussage ueber
Daten, die er nicht sehen darf.

Kontaktprotokoll als Schnellerfassung: Datum vorbelegt, Art als grosse
Knopfreihe, Dauer als Schnellwahl, ein Freitextfeld. Alles andere
eingeklappt. Ziel sind unter 20 Sekunden am Handy.

Das Haekchen "persoenlicher Kontakt zur betreuten Person" ist bewusst
NICHT vorbelegt - es wandert in den Jahresbericht nach § 1863 BGB, und
ein versehentlich gesetztes Haekchen waere dort eine falsche Angabe
gegenueber dem Gericht.

Aus demselben Grund gilt der Textbaustein "regelmaessiger persoenlicher
Kontakt" erst ab persoenlichem Kontakt in mindestens 75 Prozent der
Monate. Sonst schreibt die Auswertung ausdruecklich, dass kein
persoenlicher Kontakt dokumentiert ist. Ein geschoenter Bericht hilft
niemandem.

Protokolldaten werden als 12:00 UTC gespeichert und durchgaengig in UTC
gerechnet, sonst kippt die Zeitzone einzelne Besuchstage.

51 Tests.

Etappe: 2
Phase: Alltag
This commit is contained in:
Florian Wilde
2026-08-22 02:26:38 +02:00
parent 0cdd4edfdc
commit 6b4df4c9cb
18 changed files with 2162 additions and 6 deletions
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Auswertung für den Jahresbericht</CardTitle>
<p className="text-sm text-muted-foreground">
Zeitraum {formatDatum(statistik.von)} bis {formatDatum(statistik.bis)}
</p>
</CardHeader>
<CardContent className="space-y-6">
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Zahl beschriftung="Kontakte gesamt" wert={String(statistik.gesamt)} />
<Zahl
beschriftung="davon persönlich"
wert={String(statistik.persoenlich)}
zusatz={`an ${statistik.persoenlicheTage} ${
statistik.persoenlicheTage === 1 ? 'Tag' : 'Tagen'
}`}
/>
<Zahl
beschriftung="Besuchstage"
wert={String(statistik.besuchstage)}
zusatz={`in ${statistik.monateMitPersoenlichemKontakt} von ${statistik.monateImZeitraum} Monaten`}
/>
<Zahl
beschriftung="erfasste Dauer"
wert={dauerAusgeschrieben(statistik.gesamtdauerMinuten)}
zusatz={`zu ${statistik.mitDauerErfasst} von ${statistik.gesamt} Einträgen`}
/>
</dl>
{statistik.jeMonat.length > 0 && (
<section aria-labelledby="je-monat">
<h3 id="je-monat" className="mb-2 text-sm font-semibold">
Kontakte je Monat
</h3>
<ul className="space-y-1">
{statistik.jeMonat.map((zeile) => (
<li key={`${zeile.jahr}-${zeile.monat}`} className="flex items-center gap-3 text-sm">
<span className="w-32 shrink-0 text-muted-foreground">
{monatsname(zeile.monat)} {zeile.jahr}
</span>
<span className="flex h-3 flex-1 overflow-hidden rounded-full bg-muted">
<span
className="bg-primary"
style={{ width: `${hoechster > 0 ? (zeile.gesamt / hoechster) * 100 : 0}%` }}
/>
</span>
<span className="w-40 shrink-0 tabular-nums">
{zeile.gesamt} {zeile.gesamt === 1 ? 'Kontakt' : 'Kontakte'}, davon{' '}
{zeile.persoenlich} persönlich
</span>
</li>
))}
</ul>
</section>
)}
<section aria-labelledby="textbaustein" className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 id="textbaustein" className="text-sm font-semibold">
Textbaustein für den Bericht
</h3>
<TextKopieren text={textbaustein} ziel="textbaustein-inhalt" />
</div>
<p id="textbaustein-inhalt" className="rounded-md border bg-muted/40 p-4 leading-relaxed">
{textbaustein}
</p>
<p className="text-sm text-muted-foreground">
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.
</p>
</section>
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</CardContent>
</Card>
)
}
function Zahl({
beschriftung,
wert,
zusatz,
}: {
beschriftung: string
wert: string
zusatz?: string
}) {
return (
<div>
<dt className="text-sm text-muted-foreground">{beschriftung}</dt>
<dd className="text-xl font-semibold tabular-nums">{wert}</dd>
{zusatz && <dd className="text-sm text-muted-foreground">{zusatz}</dd>}
</div>
)
}
+158
View File
@@ -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 (
<li className={cn('py-4 first:pt-0 last:pb-0', className)}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/kontakte/${kontakt.id}`}
className="text-base font-semibold hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{kontakt.name}
</Link>
{kontakt.organisation && (
<p className="text-sm text-muted-foreground">{kontakt.organisation}</p>
)}
</div>
{kontakt.aktenzeichen && (
<Badge variant="outline" className="shrink-0 font-mono">
Az. {kontakt.aktenzeichen}
</Badge>
)}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1">
{kontakt.telefon && (
<Verweis
href={`tel:${telefonSchema(kontakt.telefon)}`}
icon={Phone}
beschriftung={`${kontakt.name} anrufen`}
>
{kontakt.telefon}
</Verweis>
)}
{kontakt.mobil && (
<Verweis
href={`tel:${telefonSchema(kontakt.mobil)}`}
icon={Smartphone}
beschriftung={`${kontakt.name} mobil anrufen`}
>
{kontakt.mobil}
</Verweis>
)}
{kontakt.email && (
<Verweis
href={`mailto:${kontakt.email}`}
icon={Mail}
beschriftung={`E-Mail an ${kontakt.name}`}
>
{kontakt.email}
</Verweis>
)}
{kontakt.fax && (
<span className="inline-flex min-h-11 items-center gap-1.5 text-sm text-muted-foreground">
<Printer className="size-4 shrink-0" aria-hidden="true" />
Fax {kontakt.fax}
</span>
)}
{anschrift && (
<span className="inline-flex min-h-11 items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
{anschrift}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
{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)}`}
{' · '}
<Link
href={`/kontakte/protokoll/neu?kontakt=${kontakt.id}`}
className="font-medium text-primary hover:underline"
>
Gespräch festhalten
</Link>
</p>
<span className="sr-only">Rolle: {KONTAKT_ROLLE_BESCHRIFTUNG[kontakt.rolle]}</span>
</li>
)
}
function Verweis({
href,
icon: Icon,
beschriftung,
children,
}: {
href: string
icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
beschriftung: string
children: React.ReactNode
}) {
return (
<a
href={href}
aria-label={beschriftung}
className={cn(
'inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-primary',
'hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<Icon className="size-4 shrink-0" aria-hidden={true} />
{children}
</a>
)
}
@@ -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<void>
id: string
betreuteId: string
frage: string
beschriftung?: string
}) {
return (
<form
action={aktion}
onSubmit={(ereignis) => {
if (!window.confirm(frage)) ereignis.preventDefault()
}}
>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="betreuteId" value={betreuteId} />
<Knopf beschriftung={beschriftung} />
</form>
)
}
function Knopf({ beschriftung }: { beschriftung: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" variant="outline" disabled={pending}>
<Trash2 aria-hidden="true" />
{pending ? 'Wird verschoben …' : beschriftung}
</Button>
)
}
@@ -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 ||
+118
View File
@@ -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 (
<ol className="space-y-3">
{zeilen.map((zeile) => (
<li
key={zeile.id}
className={cn(
'rounded-lg border p-4',
hervorgehoben === zeile.id && 'border-primary bg-primary/5',
)}
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{formatDatum(zeile.datum)}</span>
<Badge variant="secondary">{KONTAKT_ART_BESCHRIFTUNG[zeile.art]}</Badge>
{zeile.mitBetreuter && (
<Badge variant="gruen">
<User className="size-3" aria-hidden="true" />
persönlicher Kontakt
</Badge>
)}
{zeile.dauerMinuten !== null && (
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="size-3.5" aria-hidden="true" />
{dauerAusgeschrieben(zeile.dauerMinuten)}
</span>
)}
</div>
{(zeile.gespraechspartner || (!ohneKontaktspalte && zeile.kontakt) || zeile.vorgang) && (
<p className="mt-1 text-sm text-muted-foreground">
{zeile.gespraechspartner && <>mit {zeile.gespraechspartner}</>}
{!ohneKontaktspalte && zeile.kontakt && (
<>
{zeile.gespraechspartner ? ' · ' : ''}
<Link href={`/kontakte/${zeile.kontakt.id}`} className="hover:underline">
{zeile.kontakt.name}
</Link>
</>
)}
{zeile.vorgang && (
<>
{zeile.gespraechspartner || zeile.kontakt ? ' · ' : ''}
<Link href={`/vorgaenge/${zeile.vorgang.id}`} className="hover:underline">
{zeile.vorgang.titel}
</Link>
</>
)}
</p>
)}
{zeile.inhalt && <p className="mt-2 whitespace-pre-wrap">{zeile.inhalt}</p>}
{zeile.wuensche && (
<div className="mt-3 rounded-md border-l-4 border-primary bg-muted/50 p-3">
<p className="text-sm font-semibold">Geäußerte Wünsche</p>
<p className="whitespace-pre-wrap text-sm">{zeile.wuensche}</p>
</div>
)}
{zeile.folgeaufgaben && (
<div className="mt-2 rounded-md bg-muted/50 p-3">
<p className="text-sm font-semibold">Daraus folgt</p>
<p className="whitespace-pre-wrap text-sm">{zeile.folgeaufgaben}</p>
</div>
)}
<p className="mt-3">
<Link
href={`/kontakte/protokoll/${zeile.id}`}
className="inline-flex min-h-11 items-center text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Eintrag bearbeiten
</Link>
</p>
</li>
))}
</ol>
)
}
+37
View File
@@ -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 (
<Button type="button" variant="outline" size="sm" onClick={kopieren}>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Text kopieren'}
</Button>
)
}