Files
KanzlAI-mGMT/frontend/src/components/reports/CasesTab.tsx
m fdef5af32e feat: reporting dashboard — case stats, deadline compliance, workload, billing (P1)
Backend:
- ReportingService with aggregation queries (CTEs, FILTER clauses)
- 4 API endpoints: /api/reports/{cases,deadlines,workload,billing}
- Date range filtering via ?from=&to= query params

Frontend:
- /berichte page with 4 tabs: Akten, Fristen, Auslastung, Abrechnung
- recharts: bar/pie/line charts for all report types
- Date range picker, CSV export, print-friendly view
- Sidebar nav entry with BarChart3 icon

Also resolves merge conflicts between role-based, notification, and
audit trail branches, and adds missing TS types (AuditLogResponse,
Notification, NotificationPreferences).
2026-03-30 11:24:45 +02:00

224 lines
6.9 KiB
TypeScript

"use client";
import type { CaseReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
PieChart,
Pie,
Cell,
} from "recharts";
import { FolderOpen, TrendingUp, TrendingDown } from "lucide-react";
const COLORS = [
"#171717",
"#525252",
"#a3a3a3",
"#d4d4d4",
"#737373",
"#404040",
"#e5e5e5",
"#262626",
];
function formatMonth(period: string): string {
const [year, month] = period.split("-");
const months = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
}
export function CasesTab({ data }: { data: CaseReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
}));
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<FolderOpen className="h-4 w-4" />
Eröffnet
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.opened}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<TrendingDown className="h-4 w-4" />
Geschlossen
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.closed}
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<TrendingUp className="h-4 w-4" />
Aktiv
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{data.total.active}
</p>
</div>
</div>
{/* Bar chart: opened/closed per month */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Akten pro Monat
</h3>
{chartData.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten im gewählten Zeitraum
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Bar
dataKey="opened"
name="Eröffnet"
fill="#171717"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="closed"
name="Geschlossen"
fill="#a3a3a3"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Pie charts row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* By type */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Nach Verfahrensart
</h3>
{data.by_type.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="flex items-center gap-4">
<ResponsiveContainer width="50%" height={200}>
<PieChart>
<Pie
data={data.by_type}
dataKey="count"
nameKey="case_type"
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={80}
>
{data.by_type.map((_, i) => (
<Cell
key={i}
fill={COLORS[i % COLORS.length]}
/>
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{data.by_type.map((item, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<div
className="h-3 w-3 rounded-full"
style={{
backgroundColor: COLORS[i % COLORS.length],
}}
/>
<span className="text-neutral-600">{item.case_type}</span>
<span className="ml-auto font-medium text-neutral-900">
{item.count}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* By court */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Nach Gericht
</h3>
{data.by_court.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="space-y-3">
{data.by_court.map((item, i) => {
const maxCount = Math.max(...data.by_court.map((c) => c.count));
const pct = maxCount > 0 ? (item.count / maxCount) * 100 : 0;
return (
<div key={i}>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-600">{item.court}</span>
<span className="font-medium text-neutral-900">
{item.count}
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-neutral-100">
<div
className="h-2 rounded-full bg-neutral-900 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}