feat: add yearly KW comparison chart and top 10 ICD box to Dashboard

Backend: two new endpoints /reports/dashboard/yearly-comparison and
/reports/dashboard/top-icd for multi-year KW case counts and ICD
frequency aggregation.

Frontend: grouped bar chart comparing KW across years (2022+) and
a ranked top 10 ICD list with progress bars on the Dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-28 12:11:51 +00:00
parent 1e3f705ed3
commit cb73cf5461
5 changed files with 266 additions and 3 deletions

View file

@ -56,6 +56,44 @@ def dashboard(
raise HTTPException(501, "Report service not yet available") raise HTTPException(501, "Report service not yet available")
@router.get("/dashboard/yearly-comparison")
def yearly_comparison(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return per-KW case counts for each year from 2022 onwards.
Used for the multi-year comparison bar chart on the dashboard.
Accessible to both admin and dak_mitarbeiter users.
"""
from app.services.report_service import calculate_yearly_kw_comparison
data = calculate_yearly_kw_comparison(db)
return {"data": data}
@router.get("/dashboard/top-icd")
def top_icd(
jahr: int | None = Query(None),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return the top 10 most common ICD codes for the given year.
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_top_icd
items = calculate_top_icd(db, jahr)
return {"items": items}
@router.get("/weekly/{jahr}/{kw}") @router.get("/weekly/{jahr}/{kw}")
def weekly_report( def weekly_report(
jahr: int, jahr: int,

View file

@ -606,6 +606,92 @@ def calculate_dashboard_kpis(db: Session, jahr: int) -> dict:
} }
# ---------------------------------------------------------------------------
# Dashboard: Yearly KW comparison (multi-year)
# ---------------------------------------------------------------------------
def calculate_yearly_kw_comparison(db: Session) -> list[dict]:
"""Return per-KW total case counts for each year from 2022 onwards.
Returns a list of dicts like::
[
{"kw": 1, "2022": 5, "2023": 8, "2024": 12, ...},
{"kw": 2, "2022": 3, "2023": 7, "2024": 9, ...},
...
]
"""
v_filter = Case.versicherung == settings.VERSICHERUNG_FILTER
rows = (
db.query(
Case.jahr,
Case.kw,
func.count(Case.id).label("cnt"),
)
.filter(v_filter, Case.jahr >= 2022)
.group_by(Case.jahr, Case.kw)
.all()
)
# Build lookup: {kw: {year: count}}
kw_data: dict[int, dict[int, int]] = {}
all_years: set[int] = set()
for row in rows:
yr = int(row.jahr)
kw = int(row.kw)
all_years.add(yr)
if kw not in kw_data:
kw_data[kw] = {}
kw_data[kw][yr] = int(row.cnt)
sorted_years = sorted(all_years)
result = []
for kw in range(1, 53):
entry: dict[str, Any] = {"kw": kw}
for yr in sorted_years:
entry[str(yr)] = kw_data.get(kw, {}).get(yr, 0)
result.append(entry)
return result
# ---------------------------------------------------------------------------
# Dashboard: Top ICD codes
# ---------------------------------------------------------------------------
def calculate_top_icd(db: Session, jahr: int, limit: int = 10) -> list[dict]:
"""Return the most common ICD codes across all cases for the given year.
Uses the ``icd`` text field on cases (not the junction table) so it
covers all Fallgruppen, not just onko.
Returns::
[{"icd": "C50.9", "count": 42}, {"icd": "I25.1", "count": 31}, ...]
"""
v_filter = Case.versicherung == settings.VERSICHERUNG_FILTER
rows = (
db.query(
func.upper(func.trim(Case.icd)).label("icd_code"),
func.count(Case.id).label("cnt"),
)
.filter(
v_filter,
Case.jahr == jahr,
Case.icd != None, # noqa: E711
Case.icd != "",
)
.group_by(func.upper(func.trim(Case.icd)))
.order_by(func.count(Case.id).desc())
.limit(limit)
.all()
)
return [{"icd": row.icd_code, "count": int(row.cnt)} for row in rows]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Full report generation (all 5 sheets) # Full report generation (all 5 sheets)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import api from '@/services/api' import api from '@/services/api'
import type { DashboardResponse } from '@/types' import type { DashboardResponse, YearlyComparisonResponse, TopIcdResponse } from '@/types'
export function useDashboard(jahr: number) { export function useDashboard(jahr: number) {
return useQuery({ return useQuery({
@ -8,3 +8,18 @@ export function useDashboard(jahr: number) {
queryFn: () => api.get<DashboardResponse>('/reports/dashboard', { params: { jahr } }).then(r => r.data), queryFn: () => api.get<DashboardResponse>('/reports/dashboard', { params: { jahr } }).then(r => r.data),
}) })
} }
export function useYearlyComparison() {
return useQuery({
queryKey: ['dashboard', 'yearly-comparison'],
queryFn: () => api.get<YearlyComparisonResponse>('/reports/dashboard/yearly-comparison').then(r => r.data),
staleTime: 60_000,
})
}
export function useTopIcd(jahr: number) {
return useQuery({
queryKey: ['dashboard', 'top-icd', jahr],
queryFn: () => api.get<TopIcdResponse>('/reports/dashboard/top-icd', { params: { jahr } }).then(r => r.data),
})
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
@ -6,7 +6,7 @@ import {
} from 'recharts' } from 'recharts'
import { FileText, Clock, Code, Stethoscope, Info } from 'lucide-react' import { FileText, Clock, Code, Stethoscope, Info } from 'lucide-react'
import { useAuth } from '@/context/AuthContext' import { useAuth } from '@/context/AuthContext'
import { useDashboard } from '@/hooks/useDashboard' import { useDashboard, useYearlyComparison, useTopIcd } from '@/hooks/useDashboard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
@ -32,12 +32,41 @@ const CHART_COLORS = [
'var(--chart-5)', 'var(--chart-5)',
] ]
const YEAR_COLORS: Record<string, string> = {
'2022': 'var(--chart-4)',
'2023': 'var(--chart-5)',
'2024': 'var(--chart-2)',
'2025': 'var(--chart-3)',
'2026': 'var(--chart-1)',
}
export function DashboardPage() { export function DashboardPage() {
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const [jahr, setJahr] = useState(currentYear) const [jahr, setJahr] = useState(currentYear)
const { data, isLoading: loading } = useDashboard(jahr) const { data, isLoading: loading } = useDashboard(jahr)
const { data: comparisonData } = useYearlyComparison()
const { data: topIcdData } = useTopIcd(jahr)
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
// Extract year keys from comparison data (e.g. ["2022", "2023", ...])
const comparisonYears = useMemo(() => {
if (!comparisonData?.data?.[0]) return []
return Object.keys(comparisonData.data[0])
.filter((k) => k !== 'kw')
.sort()
}, [comparisonData])
// Filter out KWs where all years are 0
const filteredComparison = useMemo(() => {
if (!comparisonData?.data) return []
return comparisonData.data.filter((row) =>
comparisonYears.some((yr) => (row[yr] ?? 0) > 0)
)
}, [comparisonData, comparisonYears])
// Top ICD with max for progress bars
const topIcdMax = topIcdData?.items?.[0]?.count ?? 1
const fallgruppenData = data const fallgruppenData = data
? Object.entries(data.kpis.fallgruppen) ? Object.entries(data.kpis.fallgruppen)
.filter(([, value]) => value > 0) .filter(([, value]) => value > 0)
@ -217,6 +246,83 @@ export function DashboardPage() {
</Card> </Card>
</div> </div>
)} )}
{/* Row 2: Yearly KW comparison + Top 10 ICD */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Yearly KW comparison bar chart */}
{filteredComparison.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Jahresvergleich nach Kalenderwoche</CardTitle>
<p className="text-xs text-muted-foreground">Anzahl Fälle pro KW im Vergleich ab 2022</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={filteredComparison}>
<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={{
backgroundColor: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--popover-foreground)',
}}
/>
<Legend />
{comparisonYears.map((yr) => (
<Bar
key={yr}
dataKey={yr}
name={yr}
fill={YEAR_COLORS[yr] || 'var(--chart-1)'}
radius={[2, 2, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Top 10 ICD codes */}
{topIcdData && topIcdData.items.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Top 10 ICD-Codes</CardTitle>
<p className="text-xs text-muted-foreground">Häufigste Diagnosecodes in {jahr}</p>
</CardHeader>
<CardContent>
<div className="space-y-3">
{topIcdData.items.map((item, idx) => (
<div key={item.icd} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-mono font-medium">{item.icd}</span>
<span className="text-muted-foreground tabular-nums">{item.count}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(item.count / topIcdMax) * 100}%`,
backgroundColor: CHART_COLORS[idx % CHART_COLORS.length],
}}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div> </div>
) )
} }

View file

@ -115,6 +115,24 @@ export interface DashboardResponse {
weekly: WeeklyDataPoint[] weekly: WeeklyDataPoint[]
} }
export interface YearlyComparisonPoint {
kw: number
[year: string]: number
}
export interface YearlyComparisonResponse {
data: YearlyComparisonPoint[]
}
export interface TopIcdItem {
icd: string
count: number
}
export interface TopIcdResponse {
items: TopIcdItem[]
}
export interface Notification { export interface Notification {
id: number id: number
notification_type: string notification_type: string