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>
);
}
@@ -0,0 +1,41 @@
interface StatBarItem {
label: string;
value: number;
unit?: string;
}
interface StatBarListProps {
items: StatBarItem[];
emptyText?: string;
formatValue?: (value: number) => string;
}
export function StatBarList({ items, emptyText = "Keine Daten vorhanden.", formatValue }: StatBarListProps) {
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{emptyText}</p>;
}
const max = Math.max(...items.map((item) => item.value), 1);
return (
<div className="flex flex-col gap-3">
{items.map((item) => (
<div key={item.label} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate font-medium">{item.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatValue ? formatValue(item.value) : item.value}
{item.unit ? ` ${item.unit}` : ""}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary motion-safe:transition-[width] motion-safe:duration-700 motion-safe:ease-out"
style={{ width: `${item.value > 0 ? Math.max((item.value / max) * 100, 4) : 0}%` }}
/>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,26 @@
import { apiFetch } from "@/lib/auth/api-client";
import { ApiaryStats, ColonyStats, FeedingStats, HoneyStats, QueenStats, VarroaStats } from "./types";
export function fetchColonyStats(): Promise<ColonyStats> {
return apiFetch<ColonyStats>("/statistics/colonies");
}
export function fetchQueenStats(): Promise<QueenStats> {
return apiFetch<QueenStats>("/statistics/queens");
}
export function fetchApiaryStats(): Promise<ApiaryStats> {
return apiFetch<ApiaryStats>("/statistics/apiaries");
}
export function fetchVarroaStats(): Promise<VarroaStats> {
return apiFetch<VarroaStats>("/statistics/varroa");
}
export function fetchHoneyStats(): Promise<HoneyStats> {
return apiFetch<HoneyStats>("/statistics/honey");
}
export function fetchFeedingStats(): Promise<FeedingStats> {
return apiFetch<FeedingStats>("/statistics/feeding");
}
@@ -0,0 +1,51 @@
export interface ColonyStats {
byStatus: { status: string; count: number }[];
strengthOverTime: { colonyId: string; date: string; colonyStrength: number }[];
lossesByYear: Record<string, number>;
}
export interface QueenEvaluationAverage {
queenId: string;
code: string;
evaluationCount: number;
colonyStrength: number;
gentleness: number;
combBuild: number;
swarmingTendency: number;
broodPattern: number;
honeyYield: number;
}
export interface QueenStats {
totalActive: number;
averageLifespanDays: number | null;
evaluationAverages: QueenEvaluationAverage[];
}
export interface ApiaryStatsEntry {
apiaryId: string;
name: string;
activeColonies: number;
lostColonies: number;
totalHoneyKg: number;
averageHoneyKgPerColony: number;
}
export type ApiaryStats = ApiaryStatsEntry[];
export interface VarroaStats {
measurements: { colonyId: string; date: string; miteCount: number; method: string }[];
treatmentCountByColony: { colonyId: string; treatmentCount: number }[];
}
export interface HoneyStats {
totalHarvestKg: number;
totalHoneyBatchKg: number;
yieldPerYear: Record<string, number>;
yieldPerApiary: Record<string, number>;
}
export interface FeedingStats {
byColony: Record<string, number>;
bySeason: Record<string, number>;
}