mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 16:03:41 +00:00
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:
parent
f3846813a4
commit
42fd1808c4
7 changed files with 186 additions and 2 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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, ...}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue