feat(ui): storage, map, search, wintering and statistics without dead ends

- Every query shows loading, an error with "Erneut versuchen", an empty
  state with a next step, then data; statistics no longer hang on
  "Lädt…" after an error, a failed search no longer reads as "no hits".
- Storage: one primary action, stock items as a clearly secondary
  section, low stock as icon + word.
- Wintering: "Gassen" explained, outcome as icon + word, touch-size
  edit buttons; map: tells how many apiaries have coordinates.
- Statistics: units and plain words instead of "Ø", a select instead
  of crowded tabs on phones.
- QR code buttons are touch size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 12:22:32 +02:00
1 parent 0e46f666aa
commit 49f3db5b56
14 files changed
+708 -374

No files matched your search

@@ -9,10 +9,11 @@ import { Apiary } from "@/lib/apiaries/types";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
const ApiaryMap = dynamic(
() => import("@/components/apiary-map").then((mod) => mod.ApiaryMap),
{ ssr: false, loading: () => <p className="text-sm text-muted-foreground">Karte lädt…</p> },
{ ssr: false, loading: () => <p role="status" className="text-sm text-muted-foreground">Karte wird geladen…</p> },
);
function hasCoordinates(
@@ -22,42 +23,54 @@ function hasCoordinates(
}
export default function ApiaryMapPage() {
const { data: apiaries, isLoading, isError } = useQuery({
const apiariesQuery = useQuery({
queryKey: ["apiaries"],
queryFn: () => fetchApiaries(),
});
const located = apiaries?.filter(hasCoordinates) ?? [];
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={MapPin}
title="Kartenansicht"
description="Bienenstände mit hinterlegten Koordinaten"
description="Hier siehst du alle Bienenstände, bei denen Koordinaten hinterlegt sind."
actions={
<Button variant="outline" nativeButton={false} render={<Link href="/apiaries" />}>
<Button variant="outline" size="touch" nativeButton={false} render={<Link href="/apiaries" />}>
Zur Liste
</Button>
}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Bienenstände konnten nicht geladen werden.
</p>
) : null}
{apiaries && located.length === 0 ? (
<EmptyState
icon={MapPin}
title="Keine Koordinaten hinterlegt"
description="Trage Breiten- und Längengrad bei einem Bienenstand ein, um ihn hier zu sehen."
/>
) : null}
{located.length > 0 ? <ApiaryMap apiaries={located} /> : null}
<QueryState query={apiariesQuery} what="Bienenstände">
{(apiaries) => {
const located = apiaries.filter(hasCoordinates);
if (located.length === 0) {
return (
<EmptyState
icon={MapPin}
title="Noch kein Bienenstand auf der Karte"
description="Öffne einen Bienenstand und trage Breiten- und Längengrad ein. Danach erscheint er hier."
action={
<Button size="touch" nativeButton={false} render={<Link href="/apiaries" />}>
Zu den Bienenständen
</Button>
}
/>
);
}
return (
<>
{located.length < apiaries.length ? (
<p className="text-sm text-muted-foreground">
{located.length} von {apiaries.length} Bienenständen haben Koordinaten. Die übrigen
fehlen auf der Karte.
</p>
) : null}
<ApiaryMap apiaries={located} />
</>
);
}}
</QueryState>
</main>
);
}
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import SearchPage from "./page";
import { search } from "@/lib/search/api";
vi.mock("@/lib/search/api", () => ({
search: vi.fn(),
}));
function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("SearchPage", () => {
beforeEach(() => {
vi.mocked(search).mockReset();
});
it("says the search failed instead of showing no results", async () => {
vi.mocked(search).mockRejectedValue(new Error("offline"));
renderWithClient(<SearchPage />);
await userEvent.type(screen.getByRole("searchbox", { name: "Suchbegriff" }), "V1");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Suchergebnisse konnten nicht geladen werden",
);
expect(screen.queryByText(/Keine Treffer/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /Erneut versuchen/ })).toBeInTheDocument();
});
it("offers to clear the search when nothing matches", async () => {
vi.mocked(search).mockResolvedValue([]);
renderWithClient(<SearchPage />);
const input = screen.getByRole("searchbox", { name: "Suchbegriff" });
await userEvent.type(input, "xyz");
expect(await screen.findByText("Keine Treffer für „xyz“")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Suche leeren" }));
expect(input).toHaveValue("");
});
});
+62 -30
View File
@@ -8,7 +8,10 @@ import { search } from "@/lib/search/api";
import { SearchResult, SearchResultType } from "@/lib/search/types";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const TYPE_LABELS: Record<SearchResultType, string> = {
@@ -36,48 +39,77 @@ function resultHref(result: SearchResult): string {
export default function SearchPage() {
const [query, setQuery] = useState("");
const trimmed = query.trim();
const { data: results, isLoading } = useQuery({
queryKey: ["search", query],
queryFn: () => search(query),
enabled: query.trim().length > 0,
const searchQuery = useQuery({
queryKey: ["search", trimmed],
queryFn: () => search(trimmed),
enabled: trimmed.length > 0,
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader icon={SearchIcon} title="Suche" description="Alles auf einen Blick finden" />
<PageHeader
icon={SearchIcon}
title="Suche"
description="Finde Bienenstände, Völker, Königinnen, Aufgaben und Honigchargen."
/>
<Input
type="search"
aria-label="Suchbegriff"
className="h-11 max-w-lg"
placeholder="Bienenstand, Volk, Königin, Aufgabe, Charge…"
placeholder="z. B. Volksname, Standort, Chargennummer…"
value={query}
onChange={(event) => setQuery(event.target.value)}
autoFocus
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{query.trim().length > 0 && !isLoading && (!results || results.length === 0) ? (
<p className="text-sm text-muted-foreground">Keine Treffer.</p>
) : null}
{results && results.length > 0 ? (
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Ergebnisse</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{results.map((result) => (
<Link
key={`${result.type}-${result.id}`}
href={resultHref(result)}
className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-muted"
>
<span>{result.label}</span>
<Badge variant="secondary">{TYPE_LABELS[result.type]}</Badge>
</Link>
))}
</CardContent>
</Card>
) : null}
{trimmed.length === 0 ? (
<p className="text-sm text-muted-foreground">Tippe einen Begriff ein, um zu suchen.</p>
) : (
<QueryState
query={searchQuery}
what="Suchergebnisse"
empty={
<EmptyState
icon={SearchIcon}
title={`Keine Treffer für „${trimmed}“`}
description="Prüfe die Schreibweise oder versuche einen kürzeren Begriff."
action={
<Button size="touch" variant="outline" onClick={() => setQuery("")}>
Suche leeren
</Button>
}
/>
}
>
{(results) => <SearchResults results={results} />}
</QueryState>
)}
</main>
);
}
function SearchResults({ results }: { results: SearchResult[] }) {
return (
<Card className="max-w-lg">
<CardHeader>
<CardTitle>
{results.length} Treffer
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{results.map((result) => (
<Link
key={`${result.type}-${result.id}`}
href={resultHref(result)}
className="flex min-h-11 items-center justify-between gap-3 rounded-md border p-3 text-sm hover:bg-muted"
>
<span className="min-w-0 truncate">{result.label}</span>
<Badge variant="secondary">{TYPE_LABELS[result.type]}</Badge>
</Link>
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactElement } from "react";
import StatisticsPage from "./page";
import { fetchColonyStats } from "@/lib/statistics/api";
vi.mock("@/lib/statistics/api", () => ({
fetchColonyStats: vi.fn(),
fetchQueenStats: vi.fn(),
fetchApiaryStats: vi.fn(),
fetchVarroaStats: vi.fn(),
fetchHoneyStats: vi.fn(),
fetchFeedingStats: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({
fetchColonies: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/apiaries/api", () => ({
fetchApiaries: vi.fn().mockResolvedValue([]),
}));
function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("StatisticsPage", () => {
beforeEach(() => {
vi.mocked(fetchColonyStats).mockReset();
});
it("shows an error with retry instead of loading forever, and retry refetches", async () => {
vi.mocked(fetchColonyStats)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({
byStatus: [{ status: "ACTIVE", count: 3 }],
strengthOverTime: [],
lossesByYear: {},
});
renderWithClient(<StatisticsPage />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Völkerzahlen konnten nicht geladen werden",
);
expect(screen.queryByText(/werden geladen/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Erneut versuchen/ }));
expect(await screen.findByText("Aktiv")).toBeInTheDocument();
expect(fetchColonyStats).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
+286 -206
View File
@@ -1,6 +1,7 @@
"use client";
import { BarChart3 } from "lucide-react";
import { useState } from "react";
import { BarChart3, Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
@@ -14,8 +15,16 @@ import {
} from "@/lib/statistics/api";
import { StatBarList } from "@/components/stat-bar-list";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const COLONY_STATUS_LABELS: Record<string, string> = {
ACTIVE: "Aktiv",
@@ -28,6 +37,20 @@ const COLONY_STATUS_LABELS: Record<string, string> = {
MERGED: "Vereinigt",
};
const TABS = [
{ value: "colonies", label: "Völker" },
{ value: "queens", label: "Königinnen" },
{ value: "apiaries", label: "Bienenstände" },
{ value: "varroa", label: "Varroa" },
{ value: "honey", label: "Honig" },
{ value: "feeding", label: "Futter" },
] as const;
type StatsTab = (typeof TABS)[number]["value"];
/** Feedings are stored with a free unit (mostly litres, sometimes kg) and summed as entered. */
const FEED_UNIT = "l/kg";
function kg(value: number): string {
return value.toLocaleString("de-DE", { maximumFractionDigits: 1 });
}
@@ -47,239 +70,296 @@ function KpiTile({ label, value }: { label: string; value: string }) {
);
}
function ColonyStatsPanel() {
const { data, isLoading } = useQuery({ queryKey: ["statistics", "colonies"], queryFn: fetchColonyStats });
function PanelHint({ children }: { children: string }) {
return (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{children}</span>
</p>
);
}
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
function ColonyStatsPanel() {
const query = useQuery({ queryKey: ["statistics", "colonies"], queryFn: fetchColonyStats });
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>
<QueryState query={query} what="Völkerzahlen">
{(data) => (
<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,
}))}
emptyText="Noch keine Völker angelegt."
/>
</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>
)}
</QueryState>
);
}
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>;
const query = useQuery({ queryKey: ["statistics", "queens"], queryFn: fetchQueenStats });
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>
<QueryState query={query} what="Königinnenzahlen">
{(data) => (
<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="Durchschnittliche Lebensdauer"
value={
data.averageLifespanDays !== null
? `${Math.round(data.averageLifespanDays)} Tage`
: "–"
}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Honigertrag pro Königin</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<PanelHint>
Durchschnitt deiner Bewertungen auf einer Skala von 1 (schwach) bis 5 (sehr gut).
</PanelHint>
<StatBarList
items={data.evaluationAverages
.slice(0, 10)
.map((row) => ({ label: row.code, value: row.honeyYield, unit: "von 5" }))}
emptyText="Noch keine Königinnenbewertungen erfasst."
formatValue={(value) => value.toLocaleString("de-DE", { maximumFractionDigits: 1 })}
/>
</CardContent>
</Card>
</div>
)}
</QueryState>
);
}
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>;
const query = useQuery({ queryKey: ["statistics", "apiaries"], queryFn: fetchApiaryStats });
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>
<QueryState query={query} what="Standortzahlen">
{(data) => (
<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>
)}
</QueryState>
);
}
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>;
const query = useQuery({ queryKey: ["statistics", "varroa"], queryFn: fetchVarroaStats });
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>
<QueryState query={query} what="Varroadaten">
{(data) => (
<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,
unit: row.treatmentCount === 1 ? "Behandlung" : "Behandlungen",
}))}
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>
)}
</QueryState>
);
}
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>;
const query = useQuery({ queryKey: ["statistics", "honey"], queryFn: fetchHoneyStats });
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>
<QueryState query={query} what="Honigzahlen">
{(data) => (
<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>
)}
</QueryState>
);
}
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>;
const query = useQuery({ queryKey: ["statistics", "feeding"], queryFn: fetchFeedingStats });
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>
<QueryState query={query} what="Futterdaten">
{(data) => (
<div className="flex flex-col gap-4">
<PanelHint>
Summe der eingetragenen Futtermengen in Liter bzw. kg, so wie sie bei der Fütterung
erfasst wurden.
</PanelHint>
<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,
unit: FEED_UNIT,
}))}
formatValue={kg}
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,
unit: FEED_UNIT,
}))}
formatValue={kg}
emptyText="Noch keine Fütterungen erfasst."
/>
</CardContent>
</Card>
</div>
)}
</QueryState>
);
}
function TabPicker({ value, onChange }: { value: StatsTab; onChange: (value: StatsTab) => void }) {
const current = TABS.find((tab) => tab.value === value);
return (
<Select value={value} onValueChange={(next) => (next ? onChange(next as StatsTab) : undefined)}>
<SelectTrigger className="h-11 w-full sm:hidden" aria-label="Bereich wählen">
<SelectValue>{current?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{TABS.map((tab) => (
<SelectItem key={tab.value} value={tab.value} className="min-h-11">
{tab.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default function StatisticsPage() {
const [tab, setTab] = useState<StatsTab>("colonies");
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
@@ -291,18 +371,18 @@ export default function StatisticsPage() {
<PageHeader
icon={BarChart3}
title="Statistik"
description="Auswertungen über deine Imkerei"
description="Hier siehst du Auswertungen zu Völkern, Königinnen, Ernte und Futter."
gradient="var(--gradient-sky)"
/>
<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>
<Tabs value={tab} onValueChange={(next) => setTab(next as StatsTab)}>
<TabPicker value={tab} onChange={setTab} />
<TabsList className="hidden sm:inline-flex">
{TABS.map((entry) => (
<TabsTrigger key={entry.value} value={entry.value}>
{entry.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="colonies">
<ColonyStatsPanel />
@@ -61,7 +61,7 @@ export function CreateStockItemDialog() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">+ Neuer Artikel</Button>} />
<DialogTrigger render={<Button size="touch" variant="outline">+ Neuer Artikel</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Neuer Lagerartikel</DialogTitle>
@@ -53,7 +53,7 @@ export function CreateStorageContainerDialog() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">+ Neuer Behälter</Button>} />
<DialogTrigger render={<Button size="touch">+ Neuer Behälter</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Neuer Lagerbehälter</DialogTitle>
@@ -64,8 +64,8 @@ export function EditStockItemDialog({ item }: { item: StockItem }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
<Button variant="ghost" size="touch">
<Pencil aria-hidden />
Bearbeiten
</Button>
}
@@ -50,8 +50,8 @@ export function EditStorageContainerDialog({ container }: { container: StorageCo
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
<Button variant="ghost" size="touch">
<Pencil aria-hidden />
Bearbeiten
</Button>
}
+129 -85
View File
@@ -1,14 +1,16 @@
"use client";
import { useState } from "react";
import { Package, Warehouse } from "lucide-react";
import { AlertTriangle, Package, Warehouse } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api";
import { StockItem, StorageContainer } from "@/lib/storage/types";
import { STOCK_CATEGORY_LABELS, STORAGE_CONTAINER_STATUS_LABELS } from "@/lib/storage/labels";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { Badge } from "@/components/ui/badge";
import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
@@ -19,21 +21,18 @@ import { RecordMovementDialog } from "./record-movement-dialog";
export default function StoragePage() {
const [containerTab, setContainerTab] = useState<EntityLifecycleTab>("active");
const { data: containers, isLoading: containersLoading } = useQuery({
const containersQuery = useQuery({
queryKey: ["storage-containers"],
queryFn: fetchStorageContainers,
});
const { data: stockItems, isLoading: itemsLoading } = useQuery({
const stockItemsQuery = useQuery({
queryKey: ["stock-items"],
queryFn: fetchStockItems,
});
const activeContainers = (containers ?? []).filter(
(container) => container.status !== "OUT_OF_SERVICE",
);
const historyContainers = (containers ?? []).filter(
(container) => container.status === "OUT_OF_SERVICE",
);
const containers = containersQuery.data ?? [];
const activeContainers = containers.filter((container) => container.status !== "OUT_OF_SERVICE");
const historyContainers = containers.filter((container) => container.status === "OUT_OF_SERVICE");
const visibleContainers = containerTab === "active" ? activeContainers : historyContainers;
return (
@@ -42,7 +41,7 @@ export default function StoragePage() {
<PageHeader
icon={Warehouse}
title="Lager"
description="Lagerbehälter und Lagerartikel"
description="Hier verwaltest du Lagerbehälter und den Bestand deiner Lagerartikel."
gradient="var(--gradient-sky)"
actions={<CreateStorageContainerDialog />}
/>
@@ -55,86 +54,131 @@ export default function StoragePage() {
historyCount={historyContainers.length}
/>
</div>
{containersLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{containers && visibleContainers.length === 0 ? (
<EmptyState
icon={Warehouse}
title={containerTab === "active" ? "Noch keine Lagerbehälter" : "Keine Historie"}
description={
containerTab === "active"
? "Lege deinen ersten Behälter an."
: "Außer Betrieb genommene Behälter erscheinen hier."
}
/>
) : null}
{visibleContainers.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleContainers.map((container) => (
<EntityCard
key={container.id}
icon={Warehouse}
gradient="var(--gradient-sky)"
title={<span className="font-mono">{container.code}</span>}
subtitle={container.containerType ?? "—"}
badge={
<Badge variant={container.status === "FILLED" ? "default" : "secondary"}>
{STORAGE_CONTAINER_STATUS_LABELS[container.status]}
</Badge>
}
fields={[{ label: "Standort", value: container.location ?? "—" }]}
actions={
<>
<EditStorageContainerDialog container={container} />
<QrCodeDialog entityType="storage-containers" id={container.id} label="QR" />
</>
}
/>
))}
</div>
) : null}
<QueryState query={containersQuery} what="Lagerbehälter">
{() =>
visibleContainers.length === 0 ? (
<ContainersEmpty tab={containerTab} />
) : (
<ContainerGrid containers={visibleContainers} />
)
}
</QueryState>
</section>
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Lagerartikel</h2>
<section className="flex flex-col gap-4 border-t border-border/60 pt-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold tracking-tight">Lagerartikel</h2>
<p className="text-sm text-muted-foreground">
Gläser, Deckel, Futter und anderes Material mit Bestand.
</p>
</div>
<CreateStockItemDialog />
</div>
{itemsLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{stockItems && stockItems.length === 0 ? (
<EmptyState
icon={Package}
title="Noch keine Lagerartikel"
description="Lege deinen ersten Artikel an, um den Bestand zu verfolgen."
/>
) : null}
{stockItems && stockItems.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{stockItems.map((item) => (
<EntityCard
key={item.id}
icon={Package}
gradient="var(--gradient-sky)"
title={item.name}
subtitle={STOCK_CATEGORY_LABELS[item.category]}
badge={
item.minThreshold !== null && item.quantity < item.minThreshold ? (
<Badge variant="destructive">Niedrig</Badge>
) : undefined
}
fields={[{ label: "Bestand", value: `${item.quantity} ${item.unit}` }]}
actions={
<>
<EditStockItemDialog item={item} />
<RecordMovementDialog stockItemId={item.id} />
</>
}
/>
))}
</div>
) : null}
<QueryState
query={stockItemsQuery}
what="Lagerartikel"
empty={
<EmptyState
icon={Package}
title="Noch keine Lagerartikel"
description="Lege deinen ersten Artikel an, um den Bestand zu verfolgen."
action={<CreateStockItemDialog />}
/>
}
>
{(stockItems) => <StockItemGrid stockItems={stockItems} />}
</QueryState>
</section>
</main>
);
}
function ContainersEmpty({ tab }: { tab: EntityLifecycleTab }) {
if (tab === "history") {
return (
<EmptyState
icon={Warehouse}
title="Keine Historie"
description="Außer Betrieb genommene Behälter erscheinen hier."
/>
);
}
return (
<EmptyState
icon={Warehouse}
title="Noch keine Lagerbehälter"
description="Lege deinen ersten Behälter an, z. B. einen Hobbock oder Eimer."
action={<CreateStorageContainerDialog />}
/>
);
}
function ContainerGrid({ containers }: { containers: StorageContainer[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{containers.map((container) => (
<EntityCard
key={container.id}
icon={Warehouse}
gradient="var(--gradient-sky)"
title={<span className="font-mono">{container.code}</span>}
subtitle={container.containerType ?? "—"}
badge={
<Badge variant={container.status === "FILLED" ? "default" : "secondary"}>
{STORAGE_CONTAINER_STATUS_LABELS[container.status]}
</Badge>
}
fields={[{ label: "Standort", value: container.location ?? "—" }]}
actions={
<>
<EditStorageContainerDialog container={container} />
<QrCodeDialog entityType="storage-containers" id={container.id} label="QR" />
</>
}
/>
))}
</div>
);
}
function isLowStock(item: StockItem): boolean {
return item.minThreshold !== null && item.quantity < item.minThreshold;
}
function StockItemGrid({ stockItems }: { stockItems: StockItem[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{stockItems.map((item) => (
<EntityCard
key={item.id}
icon={Package}
gradient="var(--gradient-sky)"
title={item.name}
subtitle={STOCK_CATEGORY_LABELS[item.category]}
badge={
isLowStock(item) ? (
<Badge variant="warning">
<AlertTriangle aria-hidden />
Bestand niedrig
</Badge>
) : undefined
}
fields={[
{ label: "Bestand", value: `${item.quantity} ${item.unit}` },
{
label: "Mindestbestand",
value: item.minThreshold !== null ? `${item.minThreshold} ${item.unit}` : "—",
},
]}
actions={
<>
<EditStockItemDialog item={item} />
<RecordMovementDialog stockItemId={item.id} />
</>
}
/>
))}
</div>
);
}
@@ -60,7 +60,7 @@ export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm" variant="outline">Bewegung buchen</Button>} />
<DialogTrigger render={<Button size="touch" variant="outline">Bewegung buchen</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Bestandsbewegung</DialogTitle>
+75 -22
View File
@@ -2,16 +2,31 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, ChevronRight, Snowflake } from "lucide-react";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
CircleAlert,
CircleCheck,
CircleX,
Info,
Snowflake,
} from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api";
import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels";
import { ColonyLossCause, WinterCheckWithColony, WinterCounts } from "@/lib/winter-checks/types";
import {
ColonyLossCause,
WinterCheckWithColony,
WinterCounts,
WinterOutcome,
} from "@/lib/winter-checks/types";
import { buildWinterRows, WinterRow, winterLabel, winterYearFor } from "@/lib/winter-checks/winter";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -21,13 +36,38 @@ const DATE = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit"
function autumnText(check: WinterCheckWithColony): string {
const parts = [DATE.format(new Date(check.date))];
if (check.occupiedSeams !== null) parts.push(`${check.occupiedSeams} Gassen`);
if (check.occupiedSeams !== null) parts.push(`${check.occupiedSeams} Gassen besetzt`);
if (check.feedStockKg !== null) {
parts.push(`${check.feedStockKg.toLocaleString("de-DE")} kg Futter`);
}
return parts.join(" · ");
}
const OUTCOME_ICONS = { SURVIVED: CircleCheck, WEAK: CircleAlert, LOST: CircleX } as const;
function outcomeTone(outcome: WinterOutcome): "default" | "warning" | "destructive" {
if (outcome === "LOST") return "destructive";
if (outcome === "WEAK") return "warning";
return "default";
}
function OutcomeIcon({ outcome }: { outcome: WinterOutcome }) {
const Icon = OUTCOME_ICONS[outcome];
return <Icon aria-hidden />;
}
function SeamsHint() {
return (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
<span className="font-medium text-foreground">Gassen</span> = besetzte Wabengassen, also die
Zwischenräume zwischen den Waben, in denen Bienen sitzen. Sie zeigen die Volksstärke im Winter.
</span>
</p>
);
}
function SummaryCards({ counts }: { counts: WinterCounts }) {
// Before the first Auswinterung a rate of "0 %" would read as "no losses".
const hasSpringResults = counts.survived + counts.weak + counts.lost > 0;
@@ -88,7 +128,7 @@ function RowActions({
return (
<div className="flex flex-wrap items-center gap-2">
{row.autumn ? (
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("AUTUMN", row.autumn)}>
<Button variant="ghost" size="touch" disabled={!canUpdate} onClick={() => onOpen("AUTUMN", row.autumn)}>
Eingewintert {autumnText(row.autumn)}
</Button>
) : row.isLiving && canCreate ? (
@@ -99,16 +139,9 @@ function RowActions({
<span className="text-sm text-muted-foreground">Nicht eingewintert</span>
)}
{row.spring?.outcome ? (
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("SPRING", row.spring)}>
<Badge
variant={
row.spring.outcome === "LOST"
? "destructive"
: row.spring.outcome === "WEAK"
? "warning"
: "default"
}
>
<Button variant="ghost" size="touch" disabled={!canUpdate} onClick={() => onOpen("SPRING", row.spring)}>
<Badge variant={outcomeTone(row.spring.outcome)}>
<OutcomeIcon outcome={row.spring.outcome} />
{WINTER_OUTCOME_LABELS[row.spring.outcome]}
</Badge>
{row.spring.lossCause ? LOSS_CAUSE_LABELS[row.spring.lossCause] : null}
@@ -168,8 +201,13 @@ export default function WinterPage() {
});
}
const isLoading = checksQuery.isLoading || coloniesQuery.isLoading;
const isError = checksQuery.isError || coloniesQuery.isError;
const listQueries = [checksQuery, coloniesQuery, apiariesQuery];
const isLoading = listQueries.some((query) => query.isLoading);
const isError = listQueries.some((query) => query.isError);
const isRetrying = listQueries.some((query) => query.isFetching);
function retryList() {
listQueries.filter((query) => query.isError).forEach((query) => void query.refetch());
}
return (
<div className="flex flex-col gap-6">
@@ -182,6 +220,7 @@ export default function WinterPage() {
<Button
variant="outline"
size="icon"
className="size-11 sm:size-8"
aria-label="Vorheriger Winter"
onClick={() => setWinterYear((year) => year - 1)}
>
@@ -191,6 +230,7 @@ export default function WinterPage() {
<Button
variant="outline"
size="icon"
className="size-11 sm:size-8"
aria-label="Nächster Winter"
onClick={() => setWinterYear((year) => year + 1)}
>
@@ -207,19 +247,32 @@ export default function WinterPage() {
</p>
) : null}
{summaryQuery.data ? <SummaryCards counts={summaryQuery.data.total} /> : null}
{summaryQuery.isError ? (
<ErrorState
what="Winterzahlen"
onRetry={() => void summaryQuery.refetch()}
isRetrying={summaryQuery.isFetching}
/>
) : summaryQuery.data ? (
<SummaryCards counts={summaryQuery.data.total} />
) : null}
<SeamsHint />
{isError ? (
<p className="text-sm text-destructive">
Daten konnten nicht geladen werden. Bitte die Seite neu laden.
</p>
<ErrorState what="Völker und Winterdaten" onRetry={retryList} isRetrying={isRetrying} />
) : isLoading ? (
<p className="text-sm text-muted-foreground">Wird geladen…</p>
<LoadingState what="Völker und Winterdaten" />
) : groups.length === 0 ? (
<EmptyState
icon={Snowflake}
title="Keine Völker"
title="Noch keine Völker"
description="Sobald Völker angelegt sind, lassen sie sich hier ein- und auswintern."
action={
<Button size="touch" nativeButton={false} render={<Link href="/colonies/new" />}>
Erstes Volk anlegen
</Button>
}
/>
) : (
groups.map((group) => (
@@ -1,6 +1,7 @@
"use client";
import { FormEvent, useState } from "react";
import { Check } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
@@ -142,6 +143,7 @@ export function WinterCheckDialog({
aria-pressed={outcome === value}
onClick={() => setOutcome(value)}
>
{outcome === value ? <Check aria-hidden /> : null}
{WINTER_OUTCOME_LABELS[value]}
</Button>
))}
@@ -207,9 +209,14 @@ export function WinterCheckDialog({
id="winter-seams"
inputMode="numeric"
className="h-11"
aria-describedby="winter-seams-hint"
value={occupiedSeams}
onChange={(event) => setOccupiedSeams(event.target.value)}
/>
<p id="winter-seams-hint" className="text-xs text-muted-foreground">
Zwischenräume zwischen den Waben, in denen Bienen sitzen. Mehr Gassen = stärkeres
Volk.
</p>
</div>
{!isSpring ? (
<div className="flex flex-col gap-2">