mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 17:13:42 +00:00
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:
parent
1e3f705ed3
commit
cb73cf5461
5 changed files with 266 additions and 3 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue