feat: implement Gutachten-Statistik page with KPIs and charts

Add full statistics page replacing placeholder: 4 KPI cards (total, Bestätigung,
Alternative, Uncodiert), stacked bar chart for gutachten types per KW, donut chart
for type distribution, grouped bar chart for therapy changes per KW, and horizontal
bar chart for therapy change reasons. Includes new backend endpoint and service
function combining sheet3/sheet4 data with KPI aggregation. Also adds feature
roadmap todo.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-28 12:37:08 +00:00
parent bfb0e4bfdf
commit 5da1e523d3
6 changed files with 486 additions and 12 deletions

View file

@ -94,6 +94,27 @@ def top_icd(
return {"items": items}
@router.get("/gutachten-statistik")
def gutachten_statistik(
jahr: int | None = Query(None),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return combined gutachten statistics (KPIs + weekly breakdowns).
Defaults to the current ISO year if *jahr* is not provided.
Accessible to both admin and dak_mitarbeiter users.
"""
if not jahr:
from app.utils.kw_utils import date_to_jahr
jahr = date_to_jahr(date.today())
from app.services.report_service import calculate_gutachten_statistik
return calculate_gutachten_statistik(db, jahr)
@router.get("/weekly/{jahr}/{kw}")
def weekly_report(
jahr: int,

View file

@ -692,6 +692,107 @@ def calculate_top_icd(db: Session, jahr: int, limit: int = 10) -> list[dict]:
return [{"icd": row.icd_code, "count": int(row.cnt)} for row in rows]
# ---------------------------------------------------------------------------
# Dashboard: Gutachten-Statistik (combined sheet3 + sheet4 + KPIs)
# ---------------------------------------------------------------------------
def calculate_gutachten_statistik(db: Session, jahr: int) -> dict:
"""Return combined gutachten statistics for the statistics page.
Combines:
- KPI aggregates (total, bestätigung, alternative, uncodiert, therapy changes)
- Sheet 3 weekly data (gutachten types per KW)
- Sheet 4 weekly data (therapy changes per KW)
Returns::
{
"kpis": {
"total_gutachten": int,
"bestaetigung": int,
"alternative": int,
"uncodiert": int,
"ta_ja": int,
"ta_nein": int,
"diagnosekorrektur": int,
"unterversorgung": int,
"uebertherapie": int,
},
"gutachten_weekly": [{"kw": 1, "bestaetigung": X, "alternative": X}, ...],
"therapie_weekly": [{"kw": 1, "ta_ja": X, "ta_nein": X, ...}, ...],
}
"""
v_filter = Case.versicherung == settings.VERSICHERUNG_FILTER
# KPI aggregates
total_gutachten = (
db.query(func.count(Case.id))
.filter(v_filter, Case.jahr == jahr, Case.gutachten == True) # noqa: E712
.scalar() or 0
)
typ_rows = (
db.query(Case.gutachten_typ, func.count(Case.id).label("cnt"))
.filter(v_filter, Case.jahr == jahr, Case.gutachten == True) # noqa: E712
.group_by(Case.gutachten_typ)
.all()
)
bestaetigung = 0
alternative = 0
uncodiert = 0
for row in typ_rows:
cnt = _int(row.cnt)
if row.gutachten_typ == "Bestätigung":
bestaetigung = cnt
elif row.gutachten_typ == "Alternative":
alternative = cnt
else:
uncodiert = cnt
# Therapy change aggregates
ta_row = (
db.query(
func.sum((Case.therapieaenderung == "Ja").cast(Integer)).label("ta_ja"),
func.sum((Case.therapieaenderung == "Nein").cast(Integer)).label("ta_nein"),
func.sum(Case.ta_diagnosekorrektur.cast(Integer)).label("dk"),
func.sum(Case.ta_unterversorgung.cast(Integer)).label("uv"),
func.sum(Case.ta_uebertherapie.cast(Integer)).label("ut"),
)
.filter(v_filter, Case.jahr == jahr, Case.gutachten == True) # noqa: E712
.first()
)
# Sheet 3 weekly (simplified: gesamt only)
sheet3 = calculate_sheet3_data(db, jahr)
gutachten_weekly = [
{
"kw": w["kw"],
"bestaetigung": w["gesamt"]["bestaetigung"],
"alternative": w["gesamt"]["alternative"],
}
for w in sheet3["weekly"]
]
# Sheet 4 weekly
sheet4 = calculate_sheet4_data(db, jahr)
therapie_weekly = sheet4["weekly"]
return {
"kpis": {
"total_gutachten": total_gutachten,
"bestaetigung": bestaetigung,
"alternative": alternative,
"uncodiert": uncodiert,
"ta_ja": _int(ta_row.ta_ja) if ta_row else 0,
"ta_nein": _int(ta_row.ta_nein) if ta_row else 0,
"diagnosekorrektur": _int(ta_row.dk) if ta_row else 0,
"unterversorgung": _int(ta_row.uv) if ta_row else 0,
"uebertherapie": _int(ta_row.ut) if ta_row else 0,
},
"gutachten_weekly": gutachten_weekly,
"therapie_weekly": therapie_weekly,
}
# ---------------------------------------------------------------------------
# Full report generation (all 5 sheets)
# ---------------------------------------------------------------------------

20
docs/todo.md Normal file
View file

@ -0,0 +1,20 @@
# DAK Portal — Feature Roadmap
## Hohe Priorität
- [ ] **Gutachten-Statistik Seite** — Route `/gutachten-statistik` existiert als Platzhalter. Visualisierung von Gutachten-Typen (Bestätigung/Alternative), Therapieänderungen, Diagnosekorrektur/Unterversorgung/Übertherapie und Trends pro KW/Jahr.
- [ ] **Fallliste als Excel exportieren** — Export-Button auf der Cases-Seite für gefilterte Falllisten als .xlsx Download. Nutzt aktive Filter (Jahr, Fallgruppe, ICD-Status).
- [ ] **E-Mail-Benachrichtigungen bei Freigabe-Entscheidung** — DAK-Mitarbeiter per E-Mail informieren, wenn ihre Freigabe-Anfrage genehmigt oder abgelehnt wurde. SMTP ist bereits konfiguriert.
## Mittlere Priorität
- [ ] **Benachrichtigungs-Center (Bell-Icon)** — UI-Element im Header mit Badge-Counter. `useNotifications()` Hook mit 60s-Polling existiert bereits, braucht nur ein Frontend-Element.
- [ ] **Dashboard: Vorjahresvergleich bei KPIs** — Prozentuale Veränderung zum Vorjahr neben den KPI-Zahlen (z.B. "+12% vs. 2025"). `vorjahr_service` existiert im Backend.
- [ ] **Batch-ICD-Eingabe** — Inline-Tabelle auf der ICD-Seite mit direkter ICD-Eingabe pro Zeile statt Einzelklick auf jeden Fall.
- [ ] **Dark Mode Toggle** — Toggle in Header oder Kontoverwaltung. `useTheme` Hook existiert bereits.
## Niedrige Priorität
- [ ] **Erweiterte Suche mit Filterspeicherung** — Häufig genutzte Filter als Presets speichern (z.B. "Onko ohne ICD 2026").
- [ ] **Dashboard: Durchlaufzeiten** — Durchschnittliche Dauer von Fallerfassung bis Gutachten visualisieren.
- [ ] **Passwort-Reset per E-Mail** — Self-Service "Passwort vergessen" auf der Login-Seite. Aktuell nur Admin-seitig möglich.

View file

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/services/api'
import type { DashboardResponse, YearlyComparisonResponse, TopIcdResponse } from '@/types'
import type { DashboardResponse, YearlyComparisonResponse, TopIcdResponse, GutachtenStatistikResponse } from '@/types'
export function useDashboard(jahr: number) {
return useQuery({
@ -23,3 +23,10 @@ export function useTopIcd(jahr: number) {
queryFn: () => api.get<TopIcdResponse>('/reports/dashboard/top-icd', { params: { jahr } }).then(r => r.data),
})
}
export function useGutachtenStatistik(jahr: number) {
return useQuery({
queryKey: ['gutachten-statistik', jahr],
queryFn: () => api.get<GutachtenStatistikResponse>('/reports/gutachten-statistik', { params: { jahr } }).then(r => r.data),
})
}

View file

@ -1,17 +1,308 @@
import { BarChart3 } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { useMemo, useState } from 'react'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
PieChart, Pie, Cell, ResponsiveContainer,
} from 'recharts'
import { Stethoscope, CheckCircle, ArrowLeftRight, Clock, Info } from 'lucide-react'
import { useGutachtenStatistik } from '@/hooks/useDashboard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip as UiTooltip, TooltipContent, TooltipTrigger,
} from '@/components/ui/tooltip'
const CHART_COLORS = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
]
export function GutachtenStatistikPage() {
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
const [jahr, setJahr] = useState(currentYear)
const { data, isLoading } = useGutachtenStatistik(jahr)
// Filter out weeks with no data for cleaner charts
const gutachtenWeekly = useMemo(() => {
if (!data?.gutachten_weekly) return []
return data.gutachten_weekly.filter((w) => w.bestaetigung > 0 || w.alternative > 0)
}, [data])
const therapieWeekly = useMemo(() => {
if (!data?.therapie_weekly) return []
return data.therapie_weekly.filter((w) => w.ta_ja > 0 || w.ta_nein > 0)
}, [data])
// Donut data for type distribution
const typDonutData = useMemo(() => {
if (!data?.kpis) return []
const items = []
if (data.kpis.bestaetigung > 0) items.push({ name: 'Bestätigung', value: data.kpis.bestaetigung })
if (data.kpis.alternative > 0) items.push({ name: 'Alternative', value: data.kpis.alternative })
if (data.kpis.uncodiert > 0) items.push({ name: 'Uncodiert', value: data.kpis.uncodiert })
return items
}, [data])
// Horizontal bar data for therapy change reasons
const reasonsData = useMemo(() => {
if (!data?.kpis) return []
return [
{ name: 'Diagnosekorrektur', value: data.kpis.diagnosekorrektur },
{ name: 'Unterversorgung', value: data.kpis.unterversorgung },
{ name: 'Übertherapie', value: data.kpis.uebertherapie },
].filter((r) => r.value > 0)
}, [data])
const reasonsMax = reasonsData.length > 0 ? Math.max(...reasonsData.map((r) => r.value)) : 1
const tooltipStyle = {
backgroundColor: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--popover-foreground)',
}
return (
<div className="p-6 space-y-4">
<h1 className="text-2xl font-bold">Gutachten-Statistik</h1>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<BarChart3 className="size-12 mb-4" />
<p className="text-lg font-medium">Kommt bald</p>
<p className="text-sm">Diese Seite wird in Kürze mit detaillierten Gutachten-Statistiken verfügbar sein.</p>
</CardContent>
</Card>
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Gutachten-Statistik</h1>
<Select value={String(jahr)} onValueChange={(v) => setJahr(Number(v))}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Jahr" />
</SelectTrigger>
<SelectContent>
{years.map((y) => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* KPI Cards */}
{isLoading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-2"><Skeleton className="h-4 w-24" /></CardHeader>
<CardContent><Skeleton className="h-8 w-16" /></CardContent>
</Card>
))}
</div>
) : data ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
title="Gutachten gesamt"
tooltip="Alle Fälle mit erstelltem Gutachten im gewählten Jahr"
value={data.kpis.total_gutachten}
icon={<Stethoscope className="size-5 text-muted-foreground" />}
/>
<KpiCard
title="Bestätigung"
tooltip="Gutachten, die die Erstmeinung bestätigen"
value={data.kpis.bestaetigung}
icon={<CheckCircle className="size-5 text-green-600" />}
/>
<KpiCard
title="Alternative"
tooltip="Gutachten mit abweichender Therapieempfehlung"
value={data.kpis.alternative}
icon={<ArrowLeftRight className="size-5 text-orange-500" />}
/>
<KpiCard
title="Uncodiert"
tooltip="Gutachten, die noch nicht klassifiziert wurden"
value={data.kpis.uncodiert}
icon={<Clock className="size-5 text-muted-foreground" />}
/>
</div>
) : (
<p className="text-muted-foreground">Keine Daten verfügbar.</p>
)}
{/* Row 1: Gutachten type per KW + Donut */}
{data && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Stacked bar: Bestätigung vs Alternative per KW */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Gutachten-Typ pro Kalenderwoche</CardTitle>
<p className="text-xs text-muted-foreground">Bestätigung und Alternative pro KW</p>
</CardHeader>
<CardContent>
{gutachtenWeekly.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>
<BarChart data={gutachtenWeekly}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="kw" tickFormatter={(v) => `${v}`} className="text-xs" interval="preserveStartEnd" />
<YAxis className="text-xs" />
<Tooltip labelFormatter={(v) => `KW ${v}`} contentStyle={tooltipStyle} />
<Legend />
<Bar dataKey="bestaetigung" name="Bestätigung" stackId="a" fill="var(--chart-3)" radius={[0, 0, 0, 0]} />
<Bar dataKey="alternative" name="Alternative" stackId="a" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-center py-16">Noch keine codierten Gutachten vorhanden.</p>
)}
</CardContent>
</Card>
{/* Donut: Type distribution */}
<Card>
<CardHeader>
<CardTitle>Typ-Verteilung</CardTitle>
<p className="text-xs text-muted-foreground">Bestätigung, Alternative, Uncodiert</p>
</CardHeader>
<CardContent>
{typDonutData.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>
<PieChart>
<Pie
data={typDonutData}
cx="50%"
cy="45%"
innerRadius={50}
outerRadius={85}
paddingAngle={3}
dataKey="value"
label={({ value }) => value}
>
{typDonutData.map((_, idx) => (
<Cell key={idx} fill={[
'var(--chart-3)',
'var(--chart-1)',
'var(--chart-4)',
][idx % 3]} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} />
<Legend
verticalAlign="bottom"
iconType="circle"
iconSize={10}
formatter={(value: string) => (
<span style={{ color: 'var(--foreground)', fontSize: '13px' }}>{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-center py-16">Keine Daten.</p>
)}
</CardContent>
</Card>
</div>
)}
{/* Row 2: Therapy changes per KW + Reasons */}
{data && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Grouped bar: TA Ja vs Nein per KW */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Therapieänderungen pro Kalenderwoche</CardTitle>
<p className="text-xs text-muted-foreground">Fälle mit und ohne Therapieänderung pro KW</p>
</CardHeader>
<CardContent>
{therapieWeekly.length > 0 ? (
<ResponsiveContainer width="100%" height={350}>
<BarChart data={therapieWeekly}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="kw" tickFormatter={(v) => `${v}`} className="text-xs" interval="preserveStartEnd" />
<YAxis className="text-xs" />
<Tooltip labelFormatter={(v) => `KW ${v}`} contentStyle={tooltipStyle} />
<Legend />
<Bar dataKey="ta_ja" name="Therapieänderung Ja" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
<Bar dataKey="ta_nein" name="Therapieänderung Nein" fill="var(--chart-2)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-center py-16">Noch keine Therapieänderungen codiert.</p>
)}
</CardContent>
</Card>
{/* Reasons for therapy change */}
<Card>
<CardHeader>
<CardTitle>Gründe für Therapieänderung</CardTitle>
<p className="text-xs text-muted-foreground">Gesamtanzahl im Jahr {jahr}</p>
</CardHeader>
<CardContent>
{reasonsData.length > 0 ? (
<div className="space-y-4 pt-4">
{reasonsData.map((item, idx) => (
<div key={item.name} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground tabular-nums">{item.value}</span>
</div>
<div className="h-3 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(item.value / reasonsMax) * 100}%`,
backgroundColor: CHART_COLORS[idx % CHART_COLORS.length],
}}
/>
</div>
</div>
))}
{/* Summary */}
{data && (
<div className="pt-4 border-t mt-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Therapieänderung Ja</span>
<span className="font-medium tabular-nums">{data.kpis.ta_ja}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Therapieänderung Nein</span>
<span className="font-medium tabular-nums">{data.kpis.ta_nein}</span>
</div>
</div>
)}
</div>
) : (
<p className="text-muted-foreground text-center py-16">Keine Therapieänderungen erfasst.</p>
)}
</CardContent>
</Card>
</div>
)}
</div>
)
}
function KpiCard({ title, tooltip, value, icon }: { title: string; tooltip?: string; value: number; icon: React.ReactNode }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
{tooltip ? (
<UiTooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 cursor-help">
{title}
<Info className="size-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</UiTooltip>
) : title}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value.toLocaleString('de-DE')}</div>
</CardContent>
</Card>
)
}

View file

@ -133,6 +133,40 @@ export interface TopIcdResponse {
items: TopIcdItem[]
}
export interface GutachtenStatistikKPIs {
total_gutachten: number
bestaetigung: number
alternative: number
uncodiert: number
ta_ja: number
ta_nein: number
diagnosekorrektur: number
unterversorgung: number
uebertherapie: number
}
export interface GutachtenWeeklyPoint {
kw: number
bestaetigung: number
alternative: number
}
export interface TherapieWeeklyPoint {
kw: number
gutachten: number
ta_ja: number
ta_nein: number
diagnosekorrektur: number
unterversorgung: number
uebertherapie: number
}
export interface GutachtenStatistikResponse {
kpis: GutachtenStatistikKPIs
gutachten_weekly: GutachtenWeeklyPoint[]
therapie_weekly: TherapieWeeklyPoint[]
}
export interface Notification {
id: number
notification_type: string