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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user