feat: reporting dashboard with charts (P1)

This commit is contained in:
m
2026-03-30 11:29:35 +02:00
15 changed files with 1798 additions and 160 deletions

View File

@@ -0,0 +1,262 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
CaseReport,
DeadlineReport,
WorkloadReport,
BillingReport,
} from "@/lib/types";
import { Breadcrumb } from "@/components/layout/Breadcrumb";
import { Skeleton } from "@/components/ui/Skeleton";
import {
AlertTriangle,
RefreshCw,
Download,
Printer,
FolderOpen,
Clock,
Users,
Receipt,
} from "lucide-react";
import { CasesTab } from "@/components/reports/CasesTab";
import { DeadlinesTab } from "@/components/reports/DeadlinesTab";
import { WorkloadTab } from "@/components/reports/WorkloadTab";
import { BillingTab } from "@/components/reports/BillingTab";
type TabKey = "cases" | "deadlines" | "workload" | "billing";
const TABS: { key: TabKey; label: string; icon: typeof FolderOpen }[] = [
{ key: "cases", label: "Akten", icon: FolderOpen },
{ key: "deadlines", label: "Fristen", icon: Clock },
{ key: "workload", label: "Auslastung", icon: Users },
{ key: "billing", label: "Abrechnung", icon: Receipt },
];
function getDefaultDateRange(): { from: string; to: string } {
const now = new Date();
const from = new Date(now.getFullYear() - 1, now.getMonth(), 1);
return {
from: from.toISOString().split("T")[0],
to: now.toISOString().split("T")[0],
};
}
function ReportSkeleton() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-72 rounded-xl" />
<Skeleton className="h-48 rounded-xl" />
</div>
);
}
export default function BerichtePage() {
const [activeTab, setActiveTab] = useState<TabKey>("cases");
const defaults = getDefaultDateRange();
const [from, setFrom] = useState(defaults.from);
const [to, setTo] = useState(defaults.to);
const queryParams = `?from=${from}&to=${to}`;
const casesQuery = useQuery({
queryKey: ["reports", "cases", from, to],
queryFn: () => api.get<CaseReport>(`/reports/cases${queryParams}`),
enabled: activeTab === "cases",
});
const deadlinesQuery = useQuery({
queryKey: ["reports", "deadlines", from, to],
queryFn: () => api.get<DeadlineReport>(`/reports/deadlines${queryParams}`),
enabled: activeTab === "deadlines",
});
const workloadQuery = useQuery({
queryKey: ["reports", "workload", from, to],
queryFn: () => api.get<WorkloadReport>(`/reports/workload${queryParams}`),
enabled: activeTab === "workload",
});
const billingQuery = useQuery({
queryKey: ["reports", "billing", from, to],
queryFn: () => api.get<BillingReport>(`/reports/billing${queryParams}`),
enabled: activeTab === "billing",
});
const currentQuery = {
cases: casesQuery,
deadlines: deadlinesQuery,
workload: workloadQuery,
billing: billingQuery,
}[activeTab];
function exportCSV() {
if (!currentQuery.data) return;
let csv = "";
const data = currentQuery.data;
if (activeTab === "cases") {
const d = data as CaseReport;
csv = "Monat,Eroeffnet,Geschlossen,Aktiv\n";
csv += d.monthly
.map((r) => `${r.period},${r.opened},${r.closed},${r.active}`)
.join("\n");
} else if (activeTab === "deadlines") {
const d = data as DeadlineReport;
csv = "Monat,Gesamt,Eingehalten,Versaeumt,Ausstehend,Quote (%)\n";
csv += d.monthly
.map(
(r) =>
`${r.period},${r.total},${r.met},${r.missed},${r.pending},${r.compliance_rate.toFixed(1)}`,
)
.join("\n");
} else if (activeTab === "workload") {
const d = data as WorkloadReport;
csv = "Benutzer-ID,Aktive Akten,Fristen,Ueberfaellig,Erledigt\n";
csv += d.users
.map(
(r) =>
`${r.user_id},${r.active_cases},${r.deadlines},${r.overdue},${r.completed}`,
)
.join("\n");
} else if (activeTab === "billing") {
const d = data as BillingReport;
csv = "Monat,Aktiv,Geschlossen,Neu\n";
csv += d.monthly
.map(
(r) =>
`${r.period},${r.cases_active},${r.cases_closed},${r.cases_new}`,
)
.join("\n");
}
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `bericht-${activeTab}-${from}-${to}.csv`;
link.click();
URL.revokeObjectURL(url);
}
return (
<div className="animate-fade-in mx-auto max-w-6xl space-y-6 print:max-w-none">
<div className="print:hidden">
<Breadcrumb items={[{ label: "Berichte" }]} />
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold text-neutral-900">Berichte</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Statistiken und Auswertungen
</p>
</div>
<div className="flex items-center gap-3 print:hidden">
<div className="flex items-center gap-2 text-sm">
<label className="text-neutral-500">Von</label>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-neutral-400"
/>
<label className="text-neutral-500">Bis</label>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-neutral-400"
/>
</div>
<button
onClick={exportCSV}
disabled={!currentQuery.data}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:opacity-50"
>
<Download className="h-3.5 w-3.5" />
CSV
</button>
<button
onClick={() => window.print()}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
>
<Printer className="h-3.5 w-3.5" />
Drucken
</button>
</div>
</div>
{/* Tabs */}
<div className="border-b border-neutral-200 print:hidden">
<nav className="-mb-px flex gap-6">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 border-b-2 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.key
? "border-neutral-900 text-neutral-900"
: "border-transparent text-neutral-500 hover:border-neutral-300 hover:text-neutral-700"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab content */}
{currentQuery.isLoading && <ReportSkeleton />}
{currentQuery.error && (
<div className="py-12 text-center">
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<h2 className="text-sm font-medium text-neutral-900">
Bericht konnte nicht geladen werden
</h2>
<p className="mt-1 text-sm text-neutral-500">
Bitte versuchen Sie es erneut.
</p>
<button
onClick={() => currentQuery.refetch()}
className="mt-4 inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<RefreshCw className="h-3.5 w-3.5" />
Erneut laden
</button>
</div>
)}
{!currentQuery.isLoading && !currentQuery.error && currentQuery.data && (
<>
{activeTab === "cases" && (
<CasesTab data={currentQuery.data as CaseReport} />
)}
{activeTab === "deadlines" && (
<DeadlinesTab data={currentQuery.data as DeadlineReport} />
)}
{activeTab === "workload" && (
<WorkloadTab data={currentQuery.data as WorkloadReport} />
)}
{activeTab === "billing" && (
<BillingTab data={currentQuery.data as BillingReport} />
)}
</>
)}
</div>
);
}

View File

@@ -8,11 +8,10 @@ import {
Clock,
Calendar,
Brain,
BarChart3,
Settings,
FileText,
Menu,
X,
Receipt,
} from "lucide-react";
import { useState, useEffect } from "react";
import { usePermissions } from "@/lib/hooks/usePermissions";
@@ -29,12 +28,7 @@ const allNavigation: NavItem[] = [
{ name: "Akten", href: "/cases", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
<<<<<<< HEAD
{ name: "Abrechnung", href: "/abrechnung", icon: Receipt, permission: "manage_billing" },
||||||| 8e65463
=======
{ name: "Vorlagen", href: "/vorlagen", icon: FileText },
>>>>>>> mai/ritchie/p1-document-templates
{ name: "Berichte", href: "/berichte", icon: BarChart3 },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
];

View File

@@ -0,0 +1,240 @@
"use client";
import type { BillingReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
LineChart,
Line,
} from "recharts";
import { Receipt, TrendingUp, FolderOpen } from "lucide-react";
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 BillingTab({ data }: { data: BillingReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
}));
const totalNew = data.monthly.reduce((sum, m) => sum + m.cases_new, 0);
const totalClosed = data.monthly.reduce((sum, m) => sum + m.cases_closed, 0);
const totalByType = data.by_type.reduce((sum, t) => sum + t.total, 0);
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" />
Neue Mandate
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{totalNew}
</p>
<p className="mt-1 text-xs text-neutral-500">im Zeitraum</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">
<Receipt className="h-4 w-4" />
Abgeschlossen
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{totalClosed}
</p>
<p className="mt-1 text-xs text-neutral-500">abrechenbar</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" />
Verfahrensarten
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.by_type.length}
</p>
<p className="mt-1 text-xs text-neutral-500">
{totalByType} Akten gesamt
</p>
</div>
</div>
{/* New cases trend */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Umsatzentwicklung (Mandate)
</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}>
<LineChart 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 }} />
<Line
type="monotone"
dataKey="cases_new"
name="Neue Mandate"
stroke="#171717"
strokeWidth={2}
dot={{ fill: "#171717", r: 4 }}
/>
<Line
type="monotone"
dataKey="cases_closed"
name="Abgeschlossen"
stroke="#a3a3a3"
strokeWidth={2}
dot={{ fill: "#a3a3a3", r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* By type breakdown */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Mandate nach Verfahrensart
</h3>
{data.by_type.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={data.by_type} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis
type="number"
allowDecimals={false}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
/>
<YAxis
type="category"
dataKey="case_type"
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
width={100}
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Bar
dataKey="active"
name="Aktiv"
stackId="a"
fill="#171717"
/>
<Bar
dataKey="closed"
name="Geschlossen"
stackId="a"
fill="#a3a3a3"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Summary table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Zusammenfassung
</h3>
</div>
{data.by_type.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-neutral-400">
Keine Daten
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Verfahrensart</th>
<th className="px-5 py-3 font-medium text-right">Aktiv</th>
<th className="px-5 py-3 font-medium text-right">
Geschlossen
</th>
<th className="px-5 py-3 font-medium text-right">
Gesamt
</th>
</tr>
</thead>
<tbody>
{data.by_type.map((t) => (
<tr
key={t.case_type}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">
{t.case_type}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{t.active}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{t.closed}
</td>
<td className="px-5 py-3 text-right font-medium text-neutral-900">
{t.total}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
"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>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import type { DeadlineReport } from "@/lib/types";
import Link from "next/link";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { CheckCircle, XCircle, Clock, AlertTriangle } from "lucide-react";
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)}`;
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function DeadlinesTab({ data }: { data: DeadlineReport }) {
const chartData = data.monthly.map((m) => ({
...m,
name: formatMonth(m.period),
compliance_rate: Math.round(m.compliance_rate * 10) / 10,
}));
const complianceColor =
data.total.compliance_rate >= 90
? "text-emerald-600"
: data.total.compliance_rate >= 70
? "text-amber-600"
: "text-red-600";
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Clock className="h-4 w-4" />
Gesamt
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.total.total}
</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">
<CheckCircle className="h-4 w-4" />
Eingehalten
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{data.total.met}
</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">
<XCircle className="h-4 w-4" />
Versäumt
</div>
<p className="mt-2 text-2xl font-semibold text-red-600">
{data.total.missed}
</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">
<CheckCircle className="h-4 w-4" />
Einhaltungsquote
</div>
<p className={`mt-2 text-2xl font-semibold ${complianceColor}`}>
{data.total.compliance_rate.toFixed(1)}%
</p>
</div>
</div>
{/* Compliance rate over time */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Fristeneinhaltung im Zeitverlauf
</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}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} stroke="#a3a3a3" />
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12 }}
stroke="#a3a3a3"
unit="%"
/>
<Tooltip
contentStyle={{
border: "1px solid #e5e5e5",
borderRadius: 8,
fontSize: 13,
}}
formatter={(value) => [`${value}%`, "Quote"]}
/>
<Legend wrapperStyle={{ fontSize: 13 }} />
<Line
type="monotone"
dataKey="compliance_rate"
name="Einhaltungsquote"
stroke="#171717"
strokeWidth={2}
dot={{ fill: "#171717", r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Missed deadlines table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Versäumte Fristen
</h3>
</div>
{data.missed.length === 0 ? (
<div className="px-5 py-8 text-center">
<CheckCircle className="mx-auto h-8 w-8 text-emerald-400" />
<p className="mt-2 text-sm text-neutral-500">
Keine versäumten Fristen im gewählten Zeitraum
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Frist</th>
<th className="px-5 py-3 font-medium">Akte</th>
<th className="px-5 py-3 font-medium">Fällig am</th>
<th className="px-5 py-3 font-medium text-right">
Tage überfällig
</th>
</tr>
</thead>
<tbody>
{data.missed.map((d) => (
<tr
key={d.id}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">{d.title}</td>
<td className="px-5 py-3">
<Link
href={`/cases/${d.case_id}`}
className="text-neutral-600 hover:text-neutral-900"
>
{d.case_number} {d.case_title}
</Link>
</td>
<td className="px-5 py-3 text-neutral-600">
{formatDate(d.due_date)}
</td>
<td className="px-5 py-3 text-right">
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
<AlertTriangle className="h-3 w-3" />
{d.days_overdue}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import type { WorkloadReport } from "@/lib/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { Users, AlertTriangle, CheckCircle } from "lucide-react";
export function WorkloadTab({ data }: { data: WorkloadReport }) {
const chartData = data.users.map((u, i) => ({
name: `Nutzer ${i + 1}`,
user_id: u.user_id,
active_cases: u.active_cases,
deadlines: u.deadlines,
overdue: u.overdue,
completed: u.completed,
}));
const totalCases = data.users.reduce((sum, u) => sum + u.active_cases, 0);
const totalOverdue = data.users.reduce((sum, u) => sum + u.overdue, 0);
const totalCompleted = data.users.reduce((sum, u) => sum + u.completed, 0);
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">
<Users className="h-4 w-4" />
Mitarbeiter
</div>
<p className="mt-2 text-2xl font-semibold text-neutral-900">
{data.users.length}
</p>
<p className="mt-1 text-xs text-neutral-500">
{totalCases} aktive Akten gesamt
</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">
<AlertTriangle className="h-4 w-4" />
Überfällige Fristen
</div>
<p className="mt-2 text-2xl font-semibold text-red-600">
{totalOverdue}
</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">
<CheckCircle className="h-4 w-4" />
Erledigte Fristen
</div>
<p className="mt-2 text-2xl font-semibold text-emerald-600">
{totalCompleted}
</p>
</div>
</div>
{/* Stacked bar chart */}
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h3 className="mb-4 text-sm font-medium text-neutral-900">
Auslastung pro Mitarbeiter
</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="active_cases"
name="Aktive Akten"
stackId="work"
fill="#171717"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="completed"
name="Erledigt"
stackId="deadlines"
fill="#a3a3a3"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="overdue"
name="Überfällig"
stackId="deadlines"
fill="#dc2626"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Table */}
<div className="rounded-xl border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-5 py-4">
<h3 className="text-sm font-medium text-neutral-900">
Übersicht pro Mitarbeiter
</h3>
</div>
{data.users.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-neutral-400">
Keine Mitarbeiter mit zugewiesenen Akten
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-neutral-500">
<th className="px-5 py-3 font-medium">Mitarbeiter</th>
<th className="px-5 py-3 font-medium text-right">
Aktive Akten
</th>
<th className="px-5 py-3 font-medium text-right">Fristen</th>
<th className="px-5 py-3 font-medium text-right">
Überfällig
</th>
<th className="px-5 py-3 font-medium text-right">
Erledigt
</th>
</tr>
</thead>
<tbody>
{data.users.map((u, i) => (
<tr
key={u.user_id}
className="border-b border-neutral-50 last:border-b-0"
>
<td className="px-5 py-3 text-neutral-900">
Nutzer {i + 1}
<span className="ml-2 text-xs text-neutral-400">
{u.user_id.slice(0, 8)}...
</span>
</td>
<td className="px-5 py-3 text-right font-medium text-neutral-900">
{u.active_cases}
</td>
<td className="px-5 py-3 text-right text-neutral-600">
{u.deadlines}
</td>
<td className="px-5 py-3 text-right">
{u.overdue > 0 ? (
<span className="inline-flex items-center rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
{u.overdue}
</span>
) : (
<span className="text-neutral-400">0</span>
)}
</td>
<td className="px-5 py-3 text-right text-emerald-600">
{u.completed}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -405,3 +405,149 @@ export interface ExtractionResponse {
deadlines: ExtractedDeadline[];
count: number;
}
// Notification types
export interface Notification {
id: string;
tenant_id: string;
user_id: string;
type: string;
entity_type?: string;
entity_id?: string;
title: string;
body?: string;
sent_at?: string;
read_at?: string;
created_at: string;
}
export interface NotificationPreferences {
user_id: string;
tenant_id: string;
deadline_reminder_days: number[];
email_enabled: boolean;
daily_digest: boolean;
created_at: string;
updated_at: string;
}
export interface NotificationListResponse {
data: Notification[];
total: number;
}
// Audit log types
export interface AuditLogEntry {
id: number;
tenant_id: string;
user_id?: string;
action: string;
entity_type: string;
entity_id?: string;
old_values?: Record<string, unknown>;
new_values?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
total: number;
page: number;
limit: number;
}
// Reporting types
export interface CaseStats {
period: string;
opened: number;
closed: number;
active: number;
}
export interface CasesByType {
case_type: string;
count: number;
}
export interface CasesByCourt {
court: string;
count: number;
}
export interface CaseReport {
monthly: CaseStats[];
by_type: CasesByType[];
by_court: CasesByCourt[];
total: {
opened: number;
closed: number;
active: number;
};
}
export interface DeadlineCompliance {
period: string;
total: number;
met: number;
missed: number;
pending: number;
compliance_rate: number;
}
export interface MissedDeadline {
id: string;
title: string;
due_date: string;
case_id: string;
case_number: string;
case_title: string;
days_overdue: number;
}
export interface DeadlineReport {
monthly: DeadlineCompliance[];
missed: MissedDeadline[];
total: {
total: number;
met: number;
missed: number;
pending: number;
compliance_rate: number;
};
}
export interface UserWorkload {
user_id: string;
active_cases: number;
deadlines: number;
overdue: number;
completed: number;
}
export interface WorkloadReport {
users: UserWorkload[];
}
export interface BillingByMonth {
period: string;
cases_active: number;
cases_closed: number;
cases_new: number;
}
export interface BillingByType {
case_type: string;
active: number;
closed: number;
total: number;
}
export interface BillingReport {
monthly: BillingByMonth[];
by_type: BillingByType[];
}