From 5da1e523d3951acc525cbc51b40694ee76768607 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Sat, 28 Feb 2026 12:37:08 +0000 Subject: [PATCH] feat: implement Gutachten-Statistik page with KPIs and charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/reports.py | 21 ++ backend/app/services/report_service.py | 101 ++++++ docs/todo.md | 20 ++ frontend/src/hooks/useDashboard.ts | 9 +- frontend/src/pages/GutachtenStatistikPage.tsx | 313 +++++++++++++++++- frontend/src/types/index.ts | 34 ++ 6 files changed, 486 insertions(+), 12 deletions(-) create mode 100644 docs/todo.md diff --git a/backend/app/api/reports.py b/backend/app/api/reports.py index c6e122e..dc98739 100644 --- a/backend/app/api/reports.py +++ b/backend/app/api/reports.py @@ -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, diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 69fdc2f..43a06e1 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -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) # --------------------------------------------------------------------------- diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..a7af9f3 --- /dev/null +++ b/docs/todo.md @@ -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. diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index d9c100f..e1c5929 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -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('/reports/dashboard/top-icd', { params: { jahr } }).then(r => r.data), }) } + +export function useGutachtenStatistik(jahr: number) { + return useQuery({ + queryKey: ['gutachten-statistik', jahr], + queryFn: () => api.get('/reports/gutachten-statistik', { params: { jahr } }).then(r => r.data), + }) +} diff --git a/frontend/src/pages/GutachtenStatistikPage.tsx b/frontend/src/pages/GutachtenStatistikPage.tsx index d51e9aa..9bdf33c 100644 --- a/frontend/src/pages/GutachtenStatistikPage.tsx +++ b/frontend/src/pages/GutachtenStatistikPage.tsx @@ -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 ( -
-

Gutachten-Statistik

- - - -

Kommt bald

-

Diese Seite wird in Kürze mit detaillierten Gutachten-Statistiken verfügbar sein.

-
-
+
+ {/* Header */} +
+

Gutachten-Statistik

+ +
+ + {/* KPI Cards */} + {isLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + + + + ))} +
+ ) : data ? ( +
+ } + /> + } + /> + } + /> + } + /> +
+ ) : ( +

Keine Daten verfügbar.

+ )} + + {/* Row 1: Gutachten type per KW + Donut */} + {data && ( +
+ {/* Stacked bar: Bestätigung vs Alternative per KW */} + + + Gutachten-Typ pro Kalenderwoche +

Bestätigung und Alternative pro KW

+
+ + {gutachtenWeekly.length > 0 ? ( + + + + `${v}`} className="text-xs" interval="preserveStartEnd" /> + + `KW ${v}`} contentStyle={tooltipStyle} /> + + + + + + ) : ( +

Noch keine codierten Gutachten vorhanden.

+ )} +
+
+ + {/* Donut: Type distribution */} + + + Typ-Verteilung +

Bestätigung, Alternative, Uncodiert

+
+ + {typDonutData.length > 0 ? ( + + + value} + > + {typDonutData.map((_, idx) => ( + + ))} + + + ( + {value} + )} + /> + + + ) : ( +

Keine Daten.

+ )} +
+
+
+ )} + + {/* Row 2: Therapy changes per KW + Reasons */} + {data && ( +
+ {/* Grouped bar: TA Ja vs Nein per KW */} + + + Therapieänderungen pro Kalenderwoche +

Fälle mit und ohne Therapieänderung pro KW

+
+ + {therapieWeekly.length > 0 ? ( + + + + `${v}`} className="text-xs" interval="preserveStartEnd" /> + + `KW ${v}`} contentStyle={tooltipStyle} /> + + + + + + ) : ( +

Noch keine Therapieänderungen codiert.

+ )} +
+
+ + {/* Reasons for therapy change */} + + + Gründe für Therapieänderung +

Gesamtanzahl im Jahr {jahr}

+
+ + {reasonsData.length > 0 ? ( +
+ {reasonsData.map((item, idx) => ( +
+
+ {item.name} + {item.value} +
+
+
+
+
+ ))} + + {/* Summary */} + {data && ( +
+
+ Therapieänderung Ja + {data.kpis.ta_ja} +
+
+ Therapieänderung Nein + {data.kpis.ta_nein} +
+
+ )} +
+ ) : ( +

Keine Therapieänderungen erfasst.

+ )} + + +
+ )}
) } + +function KpiCard({ title, tooltip, value, icon }: { title: string; tooltip?: string; value: number; icon: React.ReactNode }) { + return ( + + + + {tooltip ? ( + + + + {title} + + + + {tooltip} + + ) : title} + + {icon} + + +
{value.toLocaleString('de-DE')}
+
+
+ ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bae73ad..4aeca73 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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