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).
263 lines
8.8 KiB
TypeScript
263 lines
8.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|