From 8856d7d0f5788a1c6c6dfcf74a79b6010a4849d1 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 23 Aug 2026 06:41:16 +0200 Subject: [PATCH] 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. --- ApiCore-Frontend/src/app/(app)/sidebar.tsx | 5 + .../src/app/(app)/statistics/page.tsx | 324 ++++++++++++++++++ .../src/components/stat-bar-list.tsx | 41 +++ ApiCore-Frontend/src/lib/statistics/api.ts | 26 ++ ApiCore-Frontend/src/lib/statistics/types.ts | 51 +++ TASKS.md | 4 +- 6 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/statistics/page.tsx create mode 100644 ApiCore-Frontend/src/components/stat-bar-list.tsx create mode 100644 ApiCore-Frontend/src/lib/statistics/api.ts create mode 100644 ApiCore-Frontend/src/lib/statistics/types.ts diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index e63c763..49f933d 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -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 = { diff --git a/ApiCore-Frontend/src/app/(app)/statistics/page.tsx b/ApiCore-Frontend/src/app/(app)/statistics/page.tsx new file mode 100644 index 0000000..aa66aa0 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/statistics/page.tsx @@ -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 = { + 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][] { + return Object.entries(record).sort(([a], [b]) => a.localeCompare(b)); +} + +function KpiTile({ label, value }: { label: string; value: string }) { + return ( + + +

{value}

+

{label}

+
+
+ ); +} + +function ColonyStatsPanel() { + const { data, isLoading } = useQuery({ queryKey: ["statistics", "colonies"], queryFn: fetchColonyStats }); + + if (isLoading || !data) return

Lädt…

; + + return ( +
+ + + Völker nach Status + + + ({ + label: COLONY_STATUS_LABELS[row.status] ?? row.status, + value: row.count, + }))} + /> + + + + + Völkerverluste pro Jahr + + + ({ + label: year, + value: count, + }))} + emptyText="Keine Verluste erfasst." + /> + + +
+ ); +} + +function QueenStatsPanel() { + const { data, isLoading } = useQuery({ queryKey: ["statistics", "queens"], queryFn: fetchQueenStats }); + + if (isLoading || !data) return

Lädt…

; + + return ( +
+
+ + +
+ + + Honigertrag pro Königin (Ø-Bewertung) + + + ({ label: row.code, value: row.honeyYield }))} + emptyText="Noch keine Königinnenbewertungen erfasst." + formatValue={(value) => value.toFixed(1)} + /> + + +
+ ); +} + +function ApiaryStatsPanel() { + const { data, isLoading } = useQuery({ queryKey: ["statistics", "apiaries"], queryFn: fetchApiaryStats }); + + if (isLoading || !data) return

Lädt…

; + + return ( + + + Honigertrag pro Bienenstand + + + b.totalHoneyKg - a.totalHoneyKg) + .map((row) => ({ label: row.name, value: row.totalHoneyKg, unit: "kg" }))} + formatValue={kg} + emptyText="Noch keine Ernten erfasst." + /> + + + ); +} + +function VarroaStatsPanel({ colonyLabels }: { colonyLabels: Map }) { + const { data, isLoading } = useQuery({ queryKey: ["statistics", "varroa"], queryFn: fetchVarroaStats }); + + if (isLoading || !data) return

Lädt…

; + + return ( +
+ + + Behandlungen pro Volk + + + b.treatmentCount - a.treatmentCount) + .slice(0, 10) + .map((row) => ({ + label: colonyLabels.get(row.colonyId) ?? row.colonyId, + value: row.treatmentCount, + }))} + emptyText="Noch keine Behandlungen erfasst." + /> + + + + + Letzte Milbenmessungen + + + ({ + label: colonyLabels.get(row.colonyId) ?? row.colonyId, + value: row.miteCount, + unit: "Milben", + }))} + emptyText="Noch keine Varroamessungen erfasst." + /> + + +
+ ); +} + +function HoneyStatsPanel({ apiaryLabels }: { apiaryLabels: Map }) { + const { data, isLoading } = useQuery({ queryKey: ["statistics", "honey"], queryFn: fetchHoneyStats }); + + if (isLoading || !data) return

Lädt…

; + + return ( +
+
+ + +
+ + + Ertrag pro Jahr + + + ({ + label: year, + value, + unit: "kg", + }))} + formatValue={kg} + emptyText="Noch keine Ernten erfasst." + /> + + + + + Ertrag pro Bienenstand + + + b - a) + .map(([apiaryId, value]) => ({ + label: apiaryLabels.get(apiaryId) ?? apiaryId, + value, + unit: "kg", + }))} + formatValue={kg} + emptyText="Noch keine Ernten erfasst." + /> + + +
+ ); +} + +function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map }) { + const { data, isLoading } = useQuery({ queryKey: ["statistics", "feeding"], queryFn: fetchFeedingStats }); + + if (isLoading || !data) return

Lädt…

; + + return ( +
+ + + Futterverbrauch pro Volk + + + b - a) + .slice(0, 10) + .map(([colonyId, value]) => ({ + label: colonyLabels.get(colonyId) ?? colonyId, + value, + }))} + emptyText="Noch keine Fütterungen erfasst." + /> + + + + + Futterverbrauch pro Saison + + + ({ + label: year, + value, + }))} + emptyText="Noch keine Fütterungen erfasst." + /> + + +
+ ); +} + +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 ( +
+
+

Statistik

+

Auswertungen über deine Imkerei

+
+ + + + Völker + Königinnen + Bienenstände + Varroa + Honig + Futter + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/ApiCore-Frontend/src/components/stat-bar-list.tsx b/ApiCore-Frontend/src/components/stat-bar-list.tsx new file mode 100644 index 0000000..8362f23 --- /dev/null +++ b/ApiCore-Frontend/src/components/stat-bar-list.tsx @@ -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

{emptyText}

; + } + + const max = Math.max(...items.map((item) => item.value), 1); + + return ( +
+ {items.map((item) => ( +
+
+ {item.label} + + {formatValue ? formatValue(item.value) : item.value} + {item.unit ? ` ${item.unit}` : ""} + +
+
+
0 ? Math.max((item.value / max) * 100, 4) : 0}%` }} + /> +
+
+ ))} +
+ ); +} diff --git a/ApiCore-Frontend/src/lib/statistics/api.ts b/ApiCore-Frontend/src/lib/statistics/api.ts new file mode 100644 index 0000000..49395a3 --- /dev/null +++ b/ApiCore-Frontend/src/lib/statistics/api.ts @@ -0,0 +1,26 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { ApiaryStats, ColonyStats, FeedingStats, HoneyStats, QueenStats, VarroaStats } from "./types"; + +export function fetchColonyStats(): Promise { + return apiFetch("/statistics/colonies"); +} + +export function fetchQueenStats(): Promise { + return apiFetch("/statistics/queens"); +} + +export function fetchApiaryStats(): Promise { + return apiFetch("/statistics/apiaries"); +} + +export function fetchVarroaStats(): Promise { + return apiFetch("/statistics/varroa"); +} + +export function fetchHoneyStats(): Promise { + return apiFetch("/statistics/honey"); +} + +export function fetchFeedingStats(): Promise { + return apiFetch("/statistics/feeding"); +} diff --git a/ApiCore-Frontend/src/lib/statistics/types.ts b/ApiCore-Frontend/src/lib/statistics/types.ts new file mode 100644 index 0000000..b21a102 --- /dev/null +++ b/ApiCore-Frontend/src/lib/statistics/types.ts @@ -0,0 +1,51 @@ +export interface ColonyStats { + byStatus: { status: string; count: number }[]; + strengthOverTime: { colonyId: string; date: string; colonyStrength: number }[]; + lossesByYear: Record; +} + +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; + yieldPerApiary: Record; +} + +export interface FeedingStats { + byColony: Record; + bySeason: Record; +} diff --git a/TASKS.md b/TASKS.md index 86cfebe..d70b2bc 100644 --- a/TASKS.md +++ b/TASKS.md @@ -219,8 +219,8 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Backend: `StatisticsModule` – Auswertungen gemäß Abschnitt 4.26 (Volksstärke-Entwicklung, Völkerverluste, Königinnen-Ertrag/Lebensdauer, Standort-Ertrag, Varroa-Verlauf, Honigertrag pro Volk/Stand/Jahr, Futterverbrauch) - Endpunkte: `GET /api/v1/statistics/{colonies|queens|apiaries|varroa|honey|feeding}` - Frontend: `/statistics` mit Diagrammen -- [ ] Backend-Modul fertig, getestet, grün, committet -- [ ] Frontend-Views fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet +- [x] Frontend-Views fertig, getestet, grün, committet ### 28. Webhooks