feat(frontend): Statistik-Seite mit animierten Auswertungen (Modul 27)

Tab-basierte /statistics-Seite (Voelker, Koeniginnen, Bienenstaende,
Varroa, Honig, Futter) mit animierten Balkendiagrammen, Sidebar-Eintrag
und Live-Verifikation ueber alle Tabs sowie Mobile-Breakpoint.
This commit is contained in:
Nick Szaunig committed 2026-08-23 06:41:16 +02:00
1 parent e8177432b0
commit 8856d7d0f5
6 files changed
+449 -2

No files matched your search

@@ -3,6 +3,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BarChart3,
CalendarDays,
Crown,
Droplet,
@@ -68,6 +69,10 @@ const NAV_GROUPS: NavGroup[] = [
{ label: "Inventar", href: "/equipment", icon: Wrench },
],
},
{
label: "Auswertung",
items: [{ label: "Statistik", href: "/statistics", icon: BarChart3 }],
},
];
const ADMIN_GROUP: NavGroup = {
@@ -0,0 +1,324 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import {
fetchApiaryStats,
fetchColonyStats,
fetchFeedingStats,
fetchHoneyStats,
fetchQueenStats,
fetchVarroaStats,
} from "@/lib/statistics/api";
import { StatBarList } from "@/components/stat-bar-list";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const COLONY_STATUS_LABELS: Record<string, string> = {
ACTIVE: "Aktiv",
WEAK: "Schwach",
CONSPICUOUS: "Auffällig",
QUEENLESS: "Weisellos",
DISSOLVED: "Aufgelöst",
SOLD: "Verkauft",
DECEASED: "Verstorben",
MERGED: "Vereinigt",
};
function kg(value: number): string {
return value.toLocaleString("de-DE", { maximumFractionDigits: 1 });
}
function sortedRecordEntries(record: Record<string, number>): [string, number][] {
return Object.entries(record).sort(([a], [b]) => a.localeCompare(b));
}
function KpiTile({ label, value }: { label: string; value: string }) {
return (
<Card size="sm">
<CardContent className="flex flex-col gap-1">
<p className="text-2xl font-semibold tabular-nums">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</CardContent>
</Card>
);
}
function ColonyStatsPanel() {
const { data, isLoading } = useQuery({ queryKey: ["statistics", "colonies"], queryFn: fetchColonyStats });
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Völker nach Status</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={data.byStatus.map((row) => ({
label: COLONY_STATUS_LABELS[row.status] ?? row.status,
value: row.count,
}))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Völkerverluste pro Jahr</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={sortedRecordEntries(data.lossesByYear).map(([year, count]) => ({
label: year,
value: count,
}))}
emptyText="Keine Verluste erfasst."
/>
</CardContent>
</Card>
</div>
);
}
function QueenStatsPanel() {
const { data, isLoading } = useQuery({ queryKey: ["statistics", "queens"], queryFn: fetchQueenStats });
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<KpiTile label="Aktive Königinnen" value={String(data.totalActive)} />
<KpiTile
label="Ø Lebensdauer (Tage)"
value={data.averageLifespanDays !== null ? Math.round(data.averageLifespanDays).toString() : "–"}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Honigertrag pro Königin (Ø-Bewertung)</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={data.evaluationAverages
.slice(0, 10)
.map((row) => ({ label: row.code, value: row.honeyYield }))}
emptyText="Noch keine Königinnenbewertungen erfasst."
formatValue={(value) => value.toFixed(1)}
/>
</CardContent>
</Card>
</div>
);
}
function ApiaryStatsPanel() {
const { data, isLoading } = useQuery({ queryKey: ["statistics", "apiaries"], queryFn: fetchApiaryStats });
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
return (
<Card>
<CardHeader>
<CardTitle>Honigertrag pro Bienenstand</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={data
.slice()
.sort((a, b) => b.totalHoneyKg - a.totalHoneyKg)
.map((row) => ({ label: row.name, value: row.totalHoneyKg, unit: "kg" }))}
formatValue={kg}
emptyText="Noch keine Ernten erfasst."
/>
</CardContent>
</Card>
);
}
function VarroaStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string> }) {
const { data, isLoading } = useQuery({ queryKey: ["statistics", "varroa"], queryFn: fetchVarroaStats });
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Behandlungen pro Volk</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={data.treatmentCountByColony
.slice()
.sort((a, b) => b.treatmentCount - a.treatmentCount)
.slice(0, 10)
.map((row) => ({
label: colonyLabels.get(row.colonyId) ?? row.colonyId,
value: row.treatmentCount,
}))}
emptyText="Noch keine Behandlungen erfasst."
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Letzte Milbenmessungen</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={data.measurements
.slice(-10)
.reverse()
.map((row) => ({
label: colonyLabels.get(row.colonyId) ?? row.colonyId,
value: row.miteCount,
unit: "Milben",
}))}
emptyText="Noch keine Varroamessungen erfasst."
/>
</CardContent>
</Card>
</div>
);
}
function HoneyStatsPanel({ apiaryLabels }: { apiaryLabels: Map<string, string> }) {
const { data, isLoading } = useQuery({ queryKey: ["statistics", "honey"], queryFn: fetchHoneyStats });
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<KpiTile label="Erntemenge gesamt" value={`${kg(data.totalHarvestKg)} kg`} />
<KpiTile label="Honigchargen gesamt" value={`${kg(data.totalHoneyBatchKg)} kg`} />
</div>
<Card>
<CardHeader>
<CardTitle>Ertrag pro Jahr</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={sortedRecordEntries(data.yieldPerYear).map(([year, value]) => ({
label: year,
value,
unit: "kg",
}))}
formatValue={kg}
emptyText="Noch keine Ernten erfasst."
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ertrag pro Bienenstand</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={Object.entries(data.yieldPerApiary)
.sort(([, a], [, b]) => b - a)
.map(([apiaryId, value]) => ({
label: apiaryLabels.get(apiaryId) ?? apiaryId,
value,
unit: "kg",
}))}
formatValue={kg}
emptyText="Noch keine Ernten erfasst."
/>
</CardContent>
</Card>
</div>
);
}
function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string> }) {
const { data, isLoading } = useQuery({ queryKey: ["statistics", "feeding"], queryFn: fetchFeedingStats });
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Futterverbrauch pro Volk</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={Object.entries(data.byColony)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([colonyId, value]) => ({
label: colonyLabels.get(colonyId) ?? colonyId,
value,
}))}
emptyText="Noch keine Fütterungen erfasst."
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Futterverbrauch pro Saison</CardTitle>
</CardHeader>
<CardContent>
<StatBarList
items={sortedRecordEntries(data.bySeason).map(([year, value]) => ({
label: year,
value,
}))}
emptyText="Noch keine Fütterungen erfasst."
/>
</CardContent>
</Card>
</div>
);
}
export default function StatisticsPage() {
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const colonyLabels = new Map((colonies ?? []).map((colony) => [colony.id, colony.code]));
const apiaryLabels = new Map((apiaries ?? []).map((apiary) => [apiary.id, apiary.name]));
return (
<main className="flex flex-col gap-6 p-4 sm:p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Statistik</h1>
<p className="text-sm text-muted-foreground">Auswertungen über deine Imkerei</p>
</div>
<Tabs defaultValue="colonies">
<TabsList className="w-full sm:w-fit">
<TabsTrigger value="colonies">Völker</TabsTrigger>
<TabsTrigger value="queens">Königinnen</TabsTrigger>
<TabsTrigger value="apiaries">Bienenstände</TabsTrigger>
<TabsTrigger value="varroa">Varroa</TabsTrigger>
<TabsTrigger value="honey">Honig</TabsTrigger>
<TabsTrigger value="feeding">Futter</TabsTrigger>
</TabsList>
<TabsContent value="colonies">
<ColonyStatsPanel />
</TabsContent>
<TabsContent value="queens">
<QueenStatsPanel />
</TabsContent>
<TabsContent value="apiaries">
<ApiaryStatsPanel />
</TabsContent>
<TabsContent value="varroa">
<VarroaStatsPanel colonyLabels={colonyLabels} />
</TabsContent>
<TabsContent value="honey">
<HoneyStatsPanel apiaryLabels={apiaryLabels} />
</TabsContent>
<TabsContent value="feeding">
<FeedingStatsPanel colonyLabels={colonyLabels} />
</TabsContent>
</Tabs>
</main>
);
}