feat: add admin-only billing status donut and top gutachter charts to Dashboard

Extends DashboardKPIs with total_abgerechnet/pending_abrechnung. Adds
new GET /reports/dashboard/top-gutachter endpoint (admin-only). Frontend
shows Abrechnungsstatus donut + Gutachter-Verteilung progress bars in a
new third row, visible only to admins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-28 15:55:30 +00:00
parent f3846813a4
commit 42fd1808c4
7 changed files with 186 additions and 2 deletions

View file

@ -110,6 +110,27 @@ def top_icd(
return {"items": items} return {"items": items}
@router.get("/dashboard/top-gutachter")
def top_gutachter(
jahr: int | None = Query(None),
db: Session = Depends(get_db),
user: User = Depends(require_admin),
):
"""Return the top 10 Gutachter by case count for the given year.
Admin only. Defaults to the current ISO year if *jahr* is not provided.
"""
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_gutachter
items = calculate_top_gutachter(db, jahr)
return {"items": items}
@router.get("/gutachten-statistik") @router.get("/gutachten-statistik")
def gutachten_statistik( def gutachten_statistik(
jahr: int | None = Query(None), jahr: int | None = Query(None),

View file

@ -13,6 +13,8 @@ class DashboardKPIs(BaseModel):
pending_icd: int pending_icd: int
pending_coding: int pending_coding: int
total_gutachten: int total_gutachten: int
total_abgerechnet: int = 0
pending_abrechnung: int = 0
fallgruppen: dict[str, int] # e.g. {"onko": 123, "kardio": 45, ...} fallgruppen: dict[str, int] # e.g. {"onko": 123, "kardio": 45, ...}

View file

@ -599,11 +599,26 @@ def calculate_dashboard_kpis(db: Session, jahr: int, max_kw: int | None = None)
else: else:
gutachten_typen["uncodiert"] = _int(row.cnt) gutachten_typen["uncodiert"] = _int(row.cnt)
# Abrechnung stats (only among cases with gutachten)
total_abgerechnet = (
db.query(func.count(Case.id))
.filter(
*base_filters,
Case.gutachten == True, # noqa: E712
Case.abgerechnet == True, # noqa: E712
)
.scalar()
or 0
)
pending_abrechnung = total_gutachten - total_abgerechnet
return { return {
"total_cases": total_cases, "total_cases": total_cases,
"pending_icd": pending_icd, "pending_icd": pending_icd,
"pending_coding": pending_coding, "pending_coding": pending_coding,
"total_gutachten": total_gutachten, "total_gutachten": total_gutachten,
"total_abgerechnet": total_abgerechnet,
"pending_abrechnung": pending_abrechnung,
"total_ablehnungen": total_ablehnungen, "total_ablehnungen": total_ablehnungen,
"total_unterlagen": total_unterlagen, "total_unterlagen": total_unterlagen,
"fallgruppen": fallgruppen, "fallgruppen": fallgruppen,
@ -697,6 +712,40 @@ 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] return [{"icd": row.icd_code, "count": int(row.cnt)} for row in rows]
# ---------------------------------------------------------------------------
# Dashboard: Top Gutachter
# ---------------------------------------------------------------------------
def calculate_top_gutachter(db: Session, jahr: int, limit: int = 10) -> list[dict]:
"""Return the most prolific Gutachter for the given year.
Returns::
[{"gutachter": "Dr. Müller", "count": 42}, ...]
"""
v_filter = Case.versicherung == settings.VERSICHERUNG_FILTER
rows = (
db.query(
func.trim(Case.gutachter).label("name"),
func.count(Case.id).label("cnt"),
)
.filter(
v_filter,
Case.jahr == jahr,
Case.gutachten == True, # noqa: E712
Case.gutachter != None, # noqa: E711
Case.gutachter != "",
)
.group_by(func.trim(Case.gutachter))
.order_by(func.count(Case.id).desc())
.limit(limit)
.all()
)
return [{"gutachter": row.name, "count": int(row.cnt)} for row in rows]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dashboard: Gutachten-Statistik (combined sheet3 + sheet4 + KPIs) # Dashboard: Gutachten-Statistik (combined sheet3 + sheet4 + KPIs)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

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, YearlyComparisonResponse, TopIcdResponse, GutachtenStatistikResponse } from '@/types' import type { DashboardResponse, YearlyComparisonResponse, TopIcdResponse, TopGutachterResponse, GutachtenStatistikResponse } from '@/types'
export function useDashboard(jahr: number) { export function useDashboard(jahr: number) {
return useQuery({ return useQuery({
@ -24,6 +24,14 @@ export function useTopIcd(jahr: number) {
}) })
} }
export function useTopGutachter(jahr: number, enabled = true) {
return useQuery({
queryKey: ['dashboard', 'top-gutachter', jahr],
queryFn: () => api.get<TopGutachterResponse>('/reports/dashboard/top-gutachter', { params: { jahr } }).then(r => r.data),
enabled,
})
}
export function useGutachtenStatistik(jahr: number) { export function useGutachtenStatistik(jahr: number) {
return useQuery({ return useQuery({
queryKey: ['gutachten-statistik', jahr], queryKey: ['gutachten-statistik', jahr],

View file

@ -6,7 +6,7 @@ import {
} from 'recharts' } from 'recharts'
import { FileText, Clock, Code, Stethoscope, Info, TrendingUp, TrendingDown, Minus } from 'lucide-react' import { FileText, Clock, Code, Stethoscope, Info, TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { useAuth } from '@/context/AuthContext' import { useAuth } from '@/context/AuthContext'
import { useDashboard, useYearlyComparison, useTopIcd } from '@/hooks/useDashboard' import { useDashboard, useYearlyComparison, useTopIcd, useTopGutachter } 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,
@ -47,6 +47,7 @@ export function DashboardPage() {
const { data: comparisonData } = useYearlyComparison() const { data: comparisonData } = useYearlyComparison()
const { data: topIcdData } = useTopIcd(jahr) const { data: topIcdData } = useTopIcd(jahr)
const { isAdmin } = useAuth() const { isAdmin } = useAuth()
const { data: topGutachterData } = useTopGutachter(jahr, isAdmin)
// Extract year keys from comparison data (e.g. ["2022", "2023", ...]) // Extract year keys from comparison data (e.g. ["2022", "2023", ...])
const comparisonYears = useMemo(() => { const comparisonYears = useMemo(() => {
@ -67,6 +68,15 @@ export function DashboardPage() {
// Top ICD with max for progress bars // Top ICD with max for progress bars
const topIcdMax = topIcdData?.items?.[0]?.count ?? 1 const topIcdMax = topIcdData?.items?.[0]?.count ?? 1
// Top Gutachter with max for progress bars (admin only)
const topGutachterMax = topGutachterData?.items?.[0]?.count ?? 1
// Abrechnungsstatus donut data (admin only)
const abrechnungsData = data ? [
{ name: 'Abgerechnet', value: data.kpis.total_abgerechnet },
{ name: 'Offen', value: data.kpis.pending_abrechnung },
].filter(d => d.value > 0) : []
const fallgruppenData = data const fallgruppenData = data
? Object.entries(data.kpis.fallgruppen) ? Object.entries(data.kpis.fallgruppen)
.filter(([, value]) => value > 0) .filter(([, value]) => value > 0)
@ -331,6 +341,87 @@ export function DashboardPage() {
</Card> </Card>
)} )}
</div> </div>
{/* Row 3: Admin-only — Abrechnungsstatus + Gutachter-Verteilung */}
{isAdmin && data && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Abrechnungsstatus Donut */}
{abrechnungsData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Abrechnungsstatus</CardTitle>
<p className="text-xs text-muted-foreground">Gutachten: abgerechnet vs. offen</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={abrechnungsData}
cx="50%"
cy="45%"
innerRadius={50}
outerRadius={85}
paddingAngle={3}
dataKey="value"
label={({ value }) => value}
>
<Cell fill="var(--chart-3)" />
<Cell fill="var(--chart-1)" />
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: '8px',
color: 'var(--popover-foreground)',
}}
/>
<Legend
verticalAlign="bottom"
iconType="circle"
iconSize={10}
formatter={(value: string) => (
<span style={{ color: 'var(--foreground)', fontSize: '13px' }}>{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Gutachter-Verteilung */}
{topGutachterData && topGutachterData.items.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Top 10 Gutachter</CardTitle>
<p className="text-xs text-muted-foreground">Gutachten pro Gutachter in {jahr}</p>
</CardHeader>
<CardContent>
<div className="space-y-3">
{topGutachterData.items.map((item, idx) => (
<div key={item.gutachter} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium truncate max-w-[200px]">{item.gutachter}</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 / topGutachterMax) * 100}%`,
backgroundColor: CHART_COLORS[idx % CHART_COLORS.length],
}}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)}
</div> </div>
) )
} }

View file

@ -158,6 +158,8 @@ export const mockDashboardKPIs: DashboardKPIs = {
pending_icd: 18, pending_icd: 18,
pending_coding: 7, pending_coding: 7,
total_gutachten: 89, total_gutachten: 89,
total_abgerechnet: 72,
pending_abrechnung: 17,
fallgruppen: { fallgruppen: {
onko: 65, onko: 65,
ortho: 42, ortho: 42,

View file

@ -98,6 +98,8 @@ export interface DashboardKPIs {
pending_icd: number pending_icd: number
pending_coding: number pending_coding: number
total_gutachten: number total_gutachten: number
total_abgerechnet: number
pending_abrechnung: number
fallgruppen: Record<string, number> fallgruppen: Record<string, number>
} }
@ -134,6 +136,15 @@ export interface TopIcdResponse {
items: TopIcdItem[] items: TopIcdItem[]
} }
export interface TopGutachterItem {
gutachter: string
count: number
}
export interface TopGutachterResponse {
items: TopGutachterItem[]
}
export interface GutachtenStatistikKPIs { export interface GutachtenStatistikKPIs {
total_gutachten: number total_gutachten: number
bestaetigung: number bestaetigung: number