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:
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 { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
|
|
||||||
const ApiaryMap = dynamic(
|
const ApiaryMap = dynamic(
|
||||||
() => import("@/components/apiary-map").then((mod) => mod.ApiaryMap),
|
() => 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(
|
function hasCoordinates(
|
||||||
@@ -22,42 +23,54 @@ function hasCoordinates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ApiaryMapPage() {
|
export default function ApiaryMapPage() {
|
||||||
const { data: apiaries, isLoading, isError } = useQuery({
|
const apiariesQuery = useQuery({
|
||||||
queryKey: ["apiaries"],
|
queryKey: ["apiaries"],
|
||||||
queryFn: () => fetchApiaries(),
|
queryFn: () => fetchApiaries(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const located = apiaries?.filter(hasCoordinates) ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={MapPin}
|
icon={MapPin}
|
||||||
title="Kartenansicht"
|
title="Kartenansicht"
|
||||||
description="Bienenstände mit hinterlegten Koordinaten"
|
description="Hier siehst du alle Bienenstände, bei denen Koordinaten hinterlegt sind."
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" nativeButton={false} render={<Link href="/apiaries" />}>
|
<Button variant="outline" size="touch" nativeButton={false} render={<Link href="/apiaries" />}>
|
||||||
Zur Liste
|
Zur Liste
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||||
{isError ? (
|
{(apiaries) => {
|
||||||
<p role="alert" className="text-sm text-destructive">
|
const located = apiaries.filter(hasCoordinates);
|
||||||
Bienenstände konnten nicht geladen werden.
|
if (located.length === 0) {
|
||||||
</p>
|
return (
|
||||||
) : null}
|
<EmptyState
|
||||||
|
icon={MapPin}
|
||||||
{apiaries && located.length === 0 ? (
|
title="Noch kein Bienenstand auf der Karte"
|
||||||
<EmptyState
|
description="Öffne einen Bienenstand und trage Breiten- und Längengrad ein. Danach erscheint er hier."
|
||||||
icon={MapPin}
|
action={
|
||||||
title="Keine Koordinaten hinterlegt"
|
<Button size="touch" nativeButton={false} render={<Link href="/apiaries" />}>
|
||||||
description="Trage Breiten- und Längengrad bei einem Bienenstand ein, um ihn hier zu sehen."
|
Zu den Bienenständen
|
||||||
/>
|
</Button>
|
||||||
) : null}
|
}
|
||||||
|
/>
|
||||||
{located.length > 0 ? <ApiaryMap apiaries={located} /> : null}
|
);
|
||||||
|
}
|
||||||
|
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>
|
</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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,10 @@ import { search } from "@/lib/search/api";
|
|||||||
import { SearchResult, SearchResultType } from "@/lib/search/types";
|
import { SearchResult, SearchResultType } from "@/lib/search/types";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { PageHeader } from "@/components/page-header";
|
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";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<SearchResultType, string> = {
|
const TYPE_LABELS: Record<SearchResultType, string> = {
|
||||||
@@ -36,48 +39,77 @@ function resultHref(result: SearchResult): string {
|
|||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const trimmed = query.trim();
|
||||||
|
|
||||||
const { data: results, isLoading } = useQuery({
|
const searchQuery = useQuery({
|
||||||
queryKey: ["search", query],
|
queryKey: ["search", trimmed],
|
||||||
queryFn: () => search(query),
|
queryFn: () => search(trimmed),
|
||||||
enabled: query.trim().length > 0,
|
enabled: trimmed.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<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
|
<Input
|
||||||
|
type="search"
|
||||||
|
aria-label="Suchbegriff"
|
||||||
className="h-11 max-w-lg"
|
className="h-11 max-w-lg"
|
||||||
placeholder="Bienenstand, Volk, Königin, Aufgabe, Charge…"
|
placeholder="z. B. Volksname, Standort, Chargennummer…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
{trimmed.length === 0 ? (
|
||||||
{query.trim().length > 0 && !isLoading && (!results || results.length === 0) ? (
|
<p className="text-sm text-muted-foreground">Tippe einen Begriff ein, um zu suchen.</p>
|
||||||
<p className="text-sm text-muted-foreground">Keine Treffer.</p>
|
) : (
|
||||||
) : null}
|
<QueryState
|
||||||
|
query={searchQuery}
|
||||||
{results && results.length > 0 ? (
|
what="Suchergebnisse"
|
||||||
<Card className="max-w-lg">
|
empty={
|
||||||
<CardHeader>
|
<EmptyState
|
||||||
<CardTitle>Ergebnisse</CardTitle>
|
icon={SearchIcon}
|
||||||
</CardHeader>
|
title={`Keine Treffer für „${trimmed}“`}
|
||||||
<CardContent className="flex flex-col gap-2">
|
description="Prüfe die Schreibweise oder versuche einen kürzeren Begriff."
|
||||||
{results.map((result) => (
|
action={
|
||||||
<Link
|
<Button size="touch" variant="outline" onClick={() => setQuery("")}>
|
||||||
key={`${result.type}-${result.id}`}
|
Suche leeren
|
||||||
href={resultHref(result)}
|
</Button>
|
||||||
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>
|
{(results) => <SearchResults results={results} />}
|
||||||
))}
|
</QueryState>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
</main>
|
</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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart3 } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { BarChart3, Info } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
@@ -14,8 +15,16 @@ import {
|
|||||||
} from "@/lib/statistics/api";
|
} from "@/lib/statistics/api";
|
||||||
import { StatBarList } from "@/components/stat-bar-list";
|
import { StatBarList } from "@/components/stat-bar-list";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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> = {
|
const COLONY_STATUS_LABELS: Record<string, string> = {
|
||||||
ACTIVE: "Aktiv",
|
ACTIVE: "Aktiv",
|
||||||
@@ -28,6 +37,20 @@ const COLONY_STATUS_LABELS: Record<string, string> = {
|
|||||||
MERGED: "Vereinigt",
|
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 {
|
function kg(value: number): string {
|
||||||
return value.toLocaleString("de-DE", { maximumFractionDigits: 1 });
|
return value.toLocaleString("de-DE", { maximumFractionDigits: 1 });
|
||||||
}
|
}
|
||||||
@@ -47,239 +70,296 @@ function KpiTile({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColonyStatsPanel() {
|
function PanelHint({ children }: { children: string }) {
|
||||||
const { data, isLoading } = useQuery({ queryKey: ["statistics", "colonies"], queryFn: fetchColonyStats });
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<QueryState query={query} what="Völkerzahlen">
|
||||||
<Card>
|
{(data) => (
|
||||||
<CardHeader>
|
<div className="flex flex-col gap-4">
|
||||||
<CardTitle>Völker nach Status</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle>Völker nach Status</CardTitle>
|
||||||
<StatBarList
|
</CardHeader>
|
||||||
items={data.byStatus.map((row) => ({
|
<CardContent>
|
||||||
label: COLONY_STATUS_LABELS[row.status] ?? row.status,
|
<StatBarList
|
||||||
value: row.count,
|
items={data.byStatus.map((row) => ({
|
||||||
}))}
|
label: COLONY_STATUS_LABELS[row.status] ?? row.status,
|
||||||
/>
|
value: row.count,
|
||||||
</CardContent>
|
}))}
|
||||||
</Card>
|
emptyText="Noch keine Völker angelegt."
|
||||||
<Card>
|
/>
|
||||||
<CardHeader>
|
</CardContent>
|
||||||
<CardTitle>Völkerverluste pro Jahr</CardTitle>
|
</Card>
|
||||||
</CardHeader>
|
<Card>
|
||||||
<CardContent>
|
<CardHeader>
|
||||||
<StatBarList
|
<CardTitle>Völkerverluste pro Jahr</CardTitle>
|
||||||
items={sortedRecordEntries(data.lossesByYear).map(([year, count]) => ({
|
</CardHeader>
|
||||||
label: year,
|
<CardContent>
|
||||||
value: count,
|
<StatBarList
|
||||||
}))}
|
items={sortedRecordEntries(data.lossesByYear).map(([year, count]) => ({
|
||||||
emptyText="Keine Verluste erfasst."
|
label: year,
|
||||||
/>
|
value: count,
|
||||||
</CardContent>
|
}))}
|
||||||
</Card>
|
emptyText="Keine Verluste erfasst."
|
||||||
</div>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QueenStatsPanel() {
|
function QueenStatsPanel() {
|
||||||
const { data, isLoading } = useQuery({ queryKey: ["statistics", "queens"], queryFn: fetchQueenStats });
|
const query = useQuery({ queryKey: ["statistics", "queens"], queryFn: fetchQueenStats });
|
||||||
|
|
||||||
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<QueryState query={query} what="Königinnenzahlen">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{(data) => (
|
||||||
<KpiTile label="Aktive Königinnen" value={String(data.totalActive)} />
|
<div className="flex flex-col gap-4">
|
||||||
<KpiTile
|
<div className="grid grid-cols-2 gap-3">
|
||||||
label="Ø Lebensdauer (Tage)"
|
<KpiTile label="Aktive Königinnen" value={String(data.totalActive)} />
|
||||||
value={data.averageLifespanDays !== null ? Math.round(data.averageLifespanDays).toString() : "–"}
|
<KpiTile
|
||||||
/>
|
label="Durchschnittliche Lebensdauer"
|
||||||
</div>
|
value={
|
||||||
<Card>
|
data.averageLifespanDays !== null
|
||||||
<CardHeader>
|
? `${Math.round(data.averageLifespanDays)} Tage`
|
||||||
<CardTitle>Honigertrag pro Königin (Ø-Bewertung)</CardTitle>
|
: "–"
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent>
|
/>
|
||||||
<StatBarList
|
</div>
|
||||||
items={data.evaluationAverages
|
<Card>
|
||||||
.slice(0, 10)
|
<CardHeader>
|
||||||
.map((row) => ({ label: row.code, value: row.honeyYield }))}
|
<CardTitle>Honigertrag pro Königin</CardTitle>
|
||||||
emptyText="Noch keine Königinnenbewertungen erfasst."
|
</CardHeader>
|
||||||
formatValue={(value) => value.toFixed(1)}
|
<CardContent className="flex flex-col gap-3">
|
||||||
/>
|
<PanelHint>
|
||||||
</CardContent>
|
Durchschnitt deiner Bewertungen auf einer Skala von 1 (schwach) bis 5 (sehr gut).
|
||||||
</Card>
|
</PanelHint>
|
||||||
</div>
|
<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() {
|
function ApiaryStatsPanel() {
|
||||||
const { data, isLoading } = useQuery({ queryKey: ["statistics", "apiaries"], queryFn: fetchApiaryStats });
|
const query = useQuery({ queryKey: ["statistics", "apiaries"], queryFn: fetchApiaryStats });
|
||||||
|
|
||||||
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<QueryState query={query} what="Standortzahlen">
|
||||||
<CardHeader>
|
{(data) => (
|
||||||
<CardTitle>Honigertrag pro Bienenstand</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle>Honigertrag pro Bienenstand</CardTitle>
|
||||||
<StatBarList
|
</CardHeader>
|
||||||
items={data
|
<CardContent>
|
||||||
.slice()
|
<StatBarList
|
||||||
.sort((a, b) => b.totalHoneyKg - a.totalHoneyKg)
|
items={data
|
||||||
.map((row) => ({ label: row.name, value: row.totalHoneyKg, unit: "kg" }))}
|
.slice()
|
||||||
formatValue={kg}
|
.sort((a, b) => b.totalHoneyKg - a.totalHoneyKg)
|
||||||
emptyText="Noch keine Ernten erfasst."
|
.map((row) => ({ label: row.name, value: row.totalHoneyKg, unit: "kg" }))}
|
||||||
/>
|
formatValue={kg}
|
||||||
</CardContent>
|
emptyText="Noch keine Ernten erfasst."
|
||||||
</Card>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VarroaStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string> }) {
|
function VarroaStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string> }) {
|
||||||
const { data, isLoading } = useQuery({ queryKey: ["statistics", "varroa"], queryFn: fetchVarroaStats });
|
const query = useQuery({ queryKey: ["statistics", "varroa"], queryFn: fetchVarroaStats });
|
||||||
|
|
||||||
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<QueryState query={query} what="Varroadaten">
|
||||||
<Card>
|
{(data) => (
|
||||||
<CardHeader>
|
<div className="flex flex-col gap-4">
|
||||||
<CardTitle>Behandlungen pro Volk</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle>Behandlungen pro Volk</CardTitle>
|
||||||
<StatBarList
|
</CardHeader>
|
||||||
items={data.treatmentCountByColony
|
<CardContent>
|
||||||
.slice()
|
<StatBarList
|
||||||
.sort((a, b) => b.treatmentCount - a.treatmentCount)
|
items={data.treatmentCountByColony
|
||||||
.slice(0, 10)
|
.slice()
|
||||||
.map((row) => ({
|
.sort((a, b) => b.treatmentCount - a.treatmentCount)
|
||||||
label: colonyLabels.get(row.colonyId) ?? row.colonyId,
|
.slice(0, 10)
|
||||||
value: row.treatmentCount,
|
.map((row) => ({
|
||||||
}))}
|
label: colonyLabels.get(row.colonyId) ?? row.colonyId,
|
||||||
emptyText="Noch keine Behandlungen erfasst."
|
value: row.treatmentCount,
|
||||||
/>
|
unit: row.treatmentCount === 1 ? "Behandlung" : "Behandlungen",
|
||||||
</CardContent>
|
}))}
|
||||||
</Card>
|
emptyText="Noch keine Behandlungen erfasst."
|
||||||
<Card>
|
/>
|
||||||
<CardHeader>
|
</CardContent>
|
||||||
<CardTitle>Letzte Milbenmessungen</CardTitle>
|
</Card>
|
||||||
</CardHeader>
|
<Card>
|
||||||
<CardContent>
|
<CardHeader>
|
||||||
<StatBarList
|
<CardTitle>Letzte Milbenmessungen</CardTitle>
|
||||||
items={data.measurements
|
</CardHeader>
|
||||||
.slice(-10)
|
<CardContent>
|
||||||
.reverse()
|
<StatBarList
|
||||||
.map((row) => ({
|
items={data.measurements
|
||||||
label: colonyLabels.get(row.colonyId) ?? row.colonyId,
|
.slice(-10)
|
||||||
value: row.miteCount,
|
.reverse()
|
||||||
unit: "Milben",
|
.map((row) => ({
|
||||||
}))}
|
label: colonyLabels.get(row.colonyId) ?? row.colonyId,
|
||||||
emptyText="Noch keine Varroamessungen erfasst."
|
value: row.miteCount,
|
||||||
/>
|
unit: "Milben",
|
||||||
</CardContent>
|
}))}
|
||||||
</Card>
|
emptyText="Noch keine Varroamessungen erfasst."
|
||||||
</div>
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HoneyStatsPanel({ apiaryLabels }: { apiaryLabels: Map<string, string> }) {
|
function HoneyStatsPanel({ apiaryLabels }: { apiaryLabels: Map<string, string> }) {
|
||||||
const { data, isLoading } = useQuery({ queryKey: ["statistics", "honey"], queryFn: fetchHoneyStats });
|
const query = useQuery({ queryKey: ["statistics", "honey"], queryFn: fetchHoneyStats });
|
||||||
|
|
||||||
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<QueryState query={query} what="Honigzahlen">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{(data) => (
|
||||||
<KpiTile label="Erntemenge gesamt" value={`${kg(data.totalHarvestKg)} kg`} />
|
<div className="flex flex-col gap-4">
|
||||||
<KpiTile label="Honigchargen gesamt" value={`${kg(data.totalHoneyBatchKg)} kg`} />
|
<div className="grid grid-cols-2 gap-3">
|
||||||
</div>
|
<KpiTile label="Erntemenge gesamt" value={`${kg(data.totalHarvestKg)} kg`} />
|
||||||
<Card>
|
<KpiTile label="Honigchargen gesamt" value={`${kg(data.totalHoneyBatchKg)} kg`} />
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle>Ertrag pro Jahr</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle>Ertrag pro Jahr</CardTitle>
|
||||||
<StatBarList
|
</CardHeader>
|
||||||
items={sortedRecordEntries(data.yieldPerYear).map(([year, value]) => ({
|
<CardContent>
|
||||||
label: year,
|
<StatBarList
|
||||||
value,
|
items={sortedRecordEntries(data.yieldPerYear).map(([year, value]) => ({
|
||||||
unit: "kg",
|
label: year,
|
||||||
}))}
|
value,
|
||||||
formatValue={kg}
|
unit: "kg",
|
||||||
emptyText="Noch keine Ernten erfasst."
|
}))}
|
||||||
/>
|
formatValue={kg}
|
||||||
</CardContent>
|
emptyText="Noch keine Ernten erfasst."
|
||||||
</Card>
|
/>
|
||||||
<Card>
|
</CardContent>
|
||||||
<CardHeader>
|
</Card>
|
||||||
<CardTitle>Ertrag pro Bienenstand</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle>Ertrag pro Bienenstand</CardTitle>
|
||||||
<StatBarList
|
</CardHeader>
|
||||||
items={Object.entries(data.yieldPerApiary)
|
<CardContent>
|
||||||
.sort(([, a], [, b]) => b - a)
|
<StatBarList
|
||||||
.map(([apiaryId, value]) => ({
|
items={Object.entries(data.yieldPerApiary)
|
||||||
label: apiaryLabels.get(apiaryId) ?? apiaryId,
|
.sort(([, a], [, b]) => b - a)
|
||||||
value,
|
.map(([apiaryId, value]) => ({
|
||||||
unit: "kg",
|
label: apiaryLabels.get(apiaryId) ?? apiaryId,
|
||||||
}))}
|
value,
|
||||||
formatValue={kg}
|
unit: "kg",
|
||||||
emptyText="Noch keine Ernten erfasst."
|
}))}
|
||||||
/>
|
formatValue={kg}
|
||||||
</CardContent>
|
emptyText="Noch keine Ernten erfasst."
|
||||||
</Card>
|
/>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string> }) {
|
function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string> }) {
|
||||||
const { data, isLoading } = useQuery({ queryKey: ["statistics", "feeding"], queryFn: fetchFeedingStats });
|
const query = useQuery({ queryKey: ["statistics", "feeding"], queryFn: fetchFeedingStats });
|
||||||
|
|
||||||
if (isLoading || !data) return <p className="text-sm text-muted-foreground">Lädt…</p>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<QueryState query={query} what="Futterdaten">
|
||||||
<Card>
|
{(data) => (
|
||||||
<CardHeader>
|
<div className="flex flex-col gap-4">
|
||||||
<CardTitle>Futterverbrauch pro Volk</CardTitle>
|
<PanelHint>
|
||||||
</CardHeader>
|
Summe der eingetragenen Futtermengen in Liter bzw. kg, so wie sie bei der Fütterung
|
||||||
<CardContent>
|
erfasst wurden.
|
||||||
<StatBarList
|
</PanelHint>
|
||||||
items={Object.entries(data.byColony)
|
<Card>
|
||||||
.sort(([, a], [, b]) => b - a)
|
<CardHeader>
|
||||||
.slice(0, 10)
|
<CardTitle>Futterverbrauch pro Volk</CardTitle>
|
||||||
.map(([colonyId, value]) => ({
|
</CardHeader>
|
||||||
label: colonyLabels.get(colonyId) ?? colonyId,
|
<CardContent>
|
||||||
value,
|
<StatBarList
|
||||||
}))}
|
items={Object.entries(data.byColony)
|
||||||
emptyText="Noch keine Fütterungen erfasst."
|
.sort(([, a], [, b]) => b - a)
|
||||||
/>
|
.slice(0, 10)
|
||||||
</CardContent>
|
.map(([colonyId, value]) => ({
|
||||||
</Card>
|
label: colonyLabels.get(colonyId) ?? colonyId,
|
||||||
<Card>
|
value,
|
||||||
<CardHeader>
|
unit: FEED_UNIT,
|
||||||
<CardTitle>Futterverbrauch pro Saison</CardTitle>
|
}))}
|
||||||
</CardHeader>
|
formatValue={kg}
|
||||||
<CardContent>
|
emptyText="Noch keine Fütterungen erfasst."
|
||||||
<StatBarList
|
/>
|
||||||
items={sortedRecordEntries(data.bySeason).map(([year, value]) => ({
|
</CardContent>
|
||||||
label: year,
|
</Card>
|
||||||
value,
|
<Card>
|
||||||
}))}
|
<CardHeader>
|
||||||
emptyText="Noch keine Fütterungen erfasst."
|
<CardTitle>Futterverbrauch pro Saison</CardTitle>
|
||||||
/>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<StatBarList
|
||||||
</div>
|
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() {
|
export default function StatisticsPage() {
|
||||||
|
const [tab, setTab] = useState<StatsTab>("colonies");
|
||||||
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||||
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
||||||
|
|
||||||
@@ -291,18 +371,18 @@ export default function StatisticsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
title="Statistik"
|
title="Statistik"
|
||||||
description="Auswertungen über deine Imkerei"
|
description="Hier siehst du Auswertungen zu Völkern, Königinnen, Ernte und Futter."
|
||||||
gradient="var(--gradient-sky)"
|
gradient="var(--gradient-sky)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="colonies">
|
<Tabs value={tab} onValueChange={(next) => setTab(next as StatsTab)}>
|
||||||
<TabsList className="w-full sm:w-fit">
|
<TabPicker value={tab} onChange={setTab} />
|
||||||
<TabsTrigger value="colonies">Völker</TabsTrigger>
|
<TabsList className="hidden sm:inline-flex">
|
||||||
<TabsTrigger value="queens">Königinnen</TabsTrigger>
|
{TABS.map((entry) => (
|
||||||
<TabsTrigger value="apiaries">Bienenstände</TabsTrigger>
|
<TabsTrigger key={entry.value} value={entry.value}>
|
||||||
<TabsTrigger value="varroa">Varroa</TabsTrigger>
|
{entry.label}
|
||||||
<TabsTrigger value="honey">Honig</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="feeding">Futter</TabsTrigger>
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="colonies">
|
<TabsContent value="colonies">
|
||||||
<ColonyStatsPanel />
|
<ColonyStatsPanel />
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function CreateStockItemDialog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="sm">+ Neuer Artikel</Button>} />
|
<DialogTrigger render={<Button size="touch" variant="outline">+ Neuer Artikel</Button>} />
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Neuer Lagerartikel</DialogTitle>
|
<DialogTitle>Neuer Lagerartikel</DialogTitle>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function CreateStorageContainerDialog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger render={<Button size="sm">+ Neuer Behälter</Button>} />
|
<DialogTrigger render={<Button size="touch">+ Neuer Behälter</Button>} />
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Neuer Lagerbehälter</DialogTitle>
|
<DialogTitle>Neuer Lagerbehälter</DialogTitle>
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export function EditStockItemDialog({ item }: { item: StockItem }) {
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="touch">
|
||||||
<Pencil className="size-3.5" />
|
<Pencil aria-hidden />
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function EditStorageContainerDialog({ container }: { container: StorageCo
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="touch">
|
||||||
<Pencil className="size-3.5" />
|
<Pencil aria-hidden />
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Package, Warehouse } from "lucide-react";
|
import { AlertTriangle, Package, Warehouse } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api";
|
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 { STOCK_CATEGORY_LABELS, STORAGE_CONTAINER_STATUS_LABELS } from "@/lib/storage/labels";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { EntityCard } from "@/components/entity-card";
|
import { EntityCard } from "@/components/entity-card";
|
||||||
|
import { QueryState } from "@/components/query-state";
|
||||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
|
import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
|
||||||
@@ -19,21 +21,18 @@ import { RecordMovementDialog } from "./record-movement-dialog";
|
|||||||
|
|
||||||
export default function StoragePage() {
|
export default function StoragePage() {
|
||||||
const [containerTab, setContainerTab] = useState<EntityLifecycleTab>("active");
|
const [containerTab, setContainerTab] = useState<EntityLifecycleTab>("active");
|
||||||
const { data: containers, isLoading: containersLoading } = useQuery({
|
const containersQuery = useQuery({
|
||||||
queryKey: ["storage-containers"],
|
queryKey: ["storage-containers"],
|
||||||
queryFn: fetchStorageContainers,
|
queryFn: fetchStorageContainers,
|
||||||
});
|
});
|
||||||
const { data: stockItems, isLoading: itemsLoading } = useQuery({
|
const stockItemsQuery = useQuery({
|
||||||
queryKey: ["stock-items"],
|
queryKey: ["stock-items"],
|
||||||
queryFn: fetchStockItems,
|
queryFn: fetchStockItems,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeContainers = (containers ?? []).filter(
|
const containers = containersQuery.data ?? [];
|
||||||
(container) => container.status !== "OUT_OF_SERVICE",
|
const activeContainers = containers.filter((container) => container.status !== "OUT_OF_SERVICE");
|
||||||
);
|
const historyContainers = containers.filter((container) => container.status === "OUT_OF_SERVICE");
|
||||||
const historyContainers = (containers ?? []).filter(
|
|
||||||
(container) => container.status === "OUT_OF_SERVICE",
|
|
||||||
);
|
|
||||||
const visibleContainers = containerTab === "active" ? activeContainers : historyContainers;
|
const visibleContainers = containerTab === "active" ? activeContainers : historyContainers;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +41,7 @@ export default function StoragePage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Warehouse}
|
icon={Warehouse}
|
||||||
title="Lager"
|
title="Lager"
|
||||||
description="Lagerbehälter und Lagerartikel"
|
description="Hier verwaltest du Lagerbehälter und den Bestand deiner Lagerartikel."
|
||||||
gradient="var(--gradient-sky)"
|
gradient="var(--gradient-sky)"
|
||||||
actions={<CreateStorageContainerDialog />}
|
actions={<CreateStorageContainerDialog />}
|
||||||
/>
|
/>
|
||||||
@@ -55,86 +54,131 @@ export default function StoragePage() {
|
|||||||
historyCount={historyContainers.length}
|
historyCount={historyContainers.length}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{containersLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
<QueryState query={containersQuery} what="Lagerbehälter">
|
||||||
{containers && visibleContainers.length === 0 ? (
|
{() =>
|
||||||
<EmptyState
|
visibleContainers.length === 0 ? (
|
||||||
icon={Warehouse}
|
<ContainersEmpty tab={containerTab} />
|
||||||
title={containerTab === "active" ? "Noch keine Lagerbehälter" : "Keine Historie"}
|
) : (
|
||||||
description={
|
<ContainerGrid containers={visibleContainers} />
|
||||||
containerTab === "active"
|
)
|
||||||
? "Lege deinen ersten Behälter an."
|
}
|
||||||
: "Außer Betrieb genommene Behälter erscheinen hier."
|
</QueryState>
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : 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}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4 border-t border-border/60 pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold tracking-tight">Lagerartikel</h2>
|
<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 />
|
<CreateStockItemDialog />
|
||||||
</div>
|
</div>
|
||||||
{itemsLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
<QueryState
|
||||||
{stockItems && stockItems.length === 0 ? (
|
query={stockItemsQuery}
|
||||||
<EmptyState
|
what="Lagerartikel"
|
||||||
icon={Package}
|
empty={
|
||||||
title="Noch keine Lagerartikel"
|
<EmptyState
|
||||||
description="Lege deinen ersten Artikel an, um den Bestand zu verfolgen."
|
icon={Package}
|
||||||
/>
|
title="Noch keine Lagerartikel"
|
||||||
) : null}
|
description="Lege deinen ersten Artikel an, um den Bestand zu verfolgen."
|
||||||
|
action={<CreateStockItemDialog />}
|
||||||
{stockItems && stockItems.length > 0 ? (
|
/>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
}
|
||||||
{stockItems.map((item) => (
|
>
|
||||||
<EntityCard
|
{(stockItems) => <StockItemGrid stockItems={stockItems} />}
|
||||||
key={item.id}
|
</QueryState>
|
||||||
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}
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<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>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Bestandsbewegung</DialogTitle>
|
<DialogTitle>Bestandsbewegung</DialogTitle>
|
||||||
|
|||||||
@@ -2,16 +2,31 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api";
|
import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api";
|
||||||
import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels";
|
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 { buildWinterRows, WinterRow, winterLabel, winterYearFor } from "@/lib/winter-checks/winter";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 {
|
function autumnText(check: WinterCheckWithColony): string {
|
||||||
const parts = [DATE.format(new Date(check.date))];
|
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) {
|
if (check.feedStockKg !== null) {
|
||||||
parts.push(`${check.feedStockKg.toLocaleString("de-DE")} kg Futter`);
|
parts.push(`${check.feedStockKg.toLocaleString("de-DE")} kg Futter`);
|
||||||
}
|
}
|
||||||
return parts.join(" · ");
|
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 }) {
|
function SummaryCards({ counts }: { counts: WinterCounts }) {
|
||||||
// Before the first Auswinterung a rate of "0 %" would read as "no losses".
|
// Before the first Auswinterung a rate of "0 %" would read as "no losses".
|
||||||
const hasSpringResults = counts.survived + counts.weak + counts.lost > 0;
|
const hasSpringResults = counts.survived + counts.weak + counts.lost > 0;
|
||||||
@@ -88,7 +128,7 @@ function RowActions({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{row.autumn ? (
|
{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)}
|
Eingewintert {autumnText(row.autumn)}
|
||||||
</Button>
|
</Button>
|
||||||
) : row.isLiving && canCreate ? (
|
) : row.isLiving && canCreate ? (
|
||||||
@@ -99,16 +139,9 @@ function RowActions({
|
|||||||
<span className="text-sm text-muted-foreground">Nicht eingewintert</span>
|
<span className="text-sm text-muted-foreground">Nicht eingewintert</span>
|
||||||
)}
|
)}
|
||||||
{row.spring?.outcome ? (
|
{row.spring?.outcome ? (
|
||||||
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("SPRING", row.spring)}>
|
<Button variant="ghost" size="touch" disabled={!canUpdate} onClick={() => onOpen("SPRING", row.spring)}>
|
||||||
<Badge
|
<Badge variant={outcomeTone(row.spring.outcome)}>
|
||||||
variant={
|
<OutcomeIcon outcome={row.spring.outcome} />
|
||||||
row.spring.outcome === "LOST"
|
|
||||||
? "destructive"
|
|
||||||
: row.spring.outcome === "WEAK"
|
|
||||||
? "warning"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{WINTER_OUTCOME_LABELS[row.spring.outcome]}
|
{WINTER_OUTCOME_LABELS[row.spring.outcome]}
|
||||||
</Badge>
|
</Badge>
|
||||||
{row.spring.lossCause ? LOSS_CAUSE_LABELS[row.spring.lossCause] : null}
|
{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 listQueries = [checksQuery, coloniesQuery, apiariesQuery];
|
||||||
const isError = checksQuery.isError || coloniesQuery.isError;
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -182,6 +220,7 @@ export default function WinterPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
className="size-11 sm:size-8"
|
||||||
aria-label="Vorheriger Winter"
|
aria-label="Vorheriger Winter"
|
||||||
onClick={() => setWinterYear((year) => year - 1)}
|
onClick={() => setWinterYear((year) => year - 1)}
|
||||||
>
|
>
|
||||||
@@ -191,6 +230,7 @@ export default function WinterPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
className="size-11 sm:size-8"
|
||||||
aria-label="Nächster Winter"
|
aria-label="Nächster Winter"
|
||||||
onClick={() => setWinterYear((year) => year + 1)}
|
onClick={() => setWinterYear((year) => year + 1)}
|
||||||
>
|
>
|
||||||
@@ -207,19 +247,32 @@ export default function WinterPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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 ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">
|
<ErrorState what="Völker und Winterdaten" onRetry={retryList} isRetrying={isRetrying} />
|
||||||
Daten konnten nicht geladen werden. Bitte die Seite neu laden.
|
|
||||||
</p>
|
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Wird geladen…</p>
|
<LoadingState what="Völker und Winterdaten" />
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Snowflake}
|
icon={Snowflake}
|
||||||
title="Keine Völker"
|
title="Noch keine Völker"
|
||||||
description="Sobald Völker angelegt sind, lassen sie sich hier ein- und auswintern."
|
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) => (
|
groups.map((group) => (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
@@ -142,6 +143,7 @@ export function WinterCheckDialog({
|
|||||||
aria-pressed={outcome === value}
|
aria-pressed={outcome === value}
|
||||||
onClick={() => setOutcome(value)}
|
onClick={() => setOutcome(value)}
|
||||||
>
|
>
|
||||||
|
{outcome === value ? <Check aria-hidden /> : null}
|
||||||
{WINTER_OUTCOME_LABELS[value]}
|
{WINTER_OUTCOME_LABELS[value]}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
@@ -207,9 +209,14 @@ export function WinterCheckDialog({
|
|||||||
id="winter-seams"
|
id="winter-seams"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="h-11"
|
className="h-11"
|
||||||
|
aria-describedby="winter-seams-hint"
|
||||||
value={occupiedSeams}
|
value={occupiedSeams}
|
||||||
onChange={(event) => setOccupiedSeams(event.target.value)}
|
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>
|
</div>
|
||||||
{!isSpring ? (
|
{!isSpring ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialog
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="touch">
|
||||||
<QrCode className="size-4" />
|
<QrCode className="size-4" />
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -79,7 +79,7 @@ export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialog
|
|||||||
{nfcSupported ? (
|
{nfcSupported ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="touch"
|
||||||
onClick={() => void handleWriteNfc()}
|
onClick={() => void handleWriteNfc()}
|
||||||
disabled={isWritingNfc}
|
disabled={isWritingNfc}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in new issue
Block a user