diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx index fe83e36..50a4d70 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/map/page.tsx @@ -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: () =>

Karte lädt…

}, + { ssr: false, loading: () =>

Karte wird geladen…

}, ); 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 (
}> + } /> - {isLoading ?

Lädt…

: null} - {isError ? ( -

- Bienenstände konnten nicht geladen werden. -

- ) : null} - - {apiaries && located.length === 0 ? ( - - ) : null} - - {located.length > 0 ? : null} + + {(apiaries) => { + const located = apiaries.filter(hasCoordinates); + if (located.length === 0) { + return ( + }> + Zu den Bienenständen + + } + /> + ); + } + return ( + <> + {located.length < apiaries.length ? ( +

+ {located.length} von {apiaries.length} Bienenständen haben Koordinaten. Die übrigen + fehlen auf der Karte. +

+ ) : null} + + + ); + }} +
); } diff --git a/ApiCore-Frontend/src/app/(app)/search/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/search/page.spec.tsx new file mode 100644 index 0000000..8a9422f --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/search/page.spec.tsx @@ -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({ui}); +} + +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(); + 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(); + 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(""); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/search/page.tsx b/ApiCore-Frontend/src/app/(app)/search/page.tsx index 491c5f4..7881312 100644 --- a/ApiCore-Frontend/src/app/(app)/search/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/search/page.tsx @@ -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 = { @@ -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 (
- + setQuery(event.target.value)} autoFocus /> - {isLoading ?

Lädt…

: null} - {query.trim().length > 0 && !isLoading && (!results || results.length === 0) ? ( -

Keine Treffer.

- ) : null} - - {results && results.length > 0 ? ( - - - Ergebnisse - - - {results.map((result) => ( - - {result.label} - {TYPE_LABELS[result.type]} - - ))} - - - ) : null} + {trimmed.length === 0 ? ( +

Tippe einen Begriff ein, um zu suchen.

+ ) : ( + setQuery("")}> + Suche leeren + + } + /> + } + > + {(results) => } + + )}
); } + +function SearchResults({ results }: { results: SearchResult[] }) { + return ( + + + + {results.length} Treffer + + + + {results.map((result) => ( + + {result.label} + {TYPE_LABELS[result.type]} + + ))} + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx new file mode 100644 index 0000000..308e11d --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/statistics/page.spec.tsx @@ -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({ui}); +} + +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(); + + 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(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/statistics/page.tsx b/ApiCore-Frontend/src/app/(app)/statistics/page.tsx index 943354a..59adc18 100644 --- a/ApiCore-Frontend/src/app/(app)/statistics/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/statistics/page.tsx @@ -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 = { ACTIVE: "Aktiv", @@ -28,6 +37,20 @@ const COLONY_STATUS_LABELS: Record = { 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 ( +

+ + {children} +

+ ); +} - if (isLoading || !data) return

Lädt…

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

Lädt…

; + const query = useQuery({ queryKey: ["statistics", "queens"], queryFn: fetchQueenStats }); return ( -
-
- - -
- - - Honigertrag pro Königin (Ø-Bewertung) - - - ({ label: row.code, value: row.honeyYield }))} - emptyText="Noch keine Königinnenbewertungen erfasst." - formatValue={(value) => value.toFixed(1)} - /> - - -
+ + {(data) => ( +
+
+ + +
+ + + Honigertrag pro Königin + + + + Durchschnitt deiner Bewertungen auf einer Skala von 1 (schwach) bis 5 (sehr gut). + + ({ label: row.code, value: row.honeyYield, unit: "von 5" }))} + emptyText="Noch keine Königinnenbewertungen erfasst." + formatValue={(value) => value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} + /> + + +
+ )} +
); } function ApiaryStatsPanel() { - const { data, isLoading } = useQuery({ queryKey: ["statistics", "apiaries"], queryFn: fetchApiaryStats }); - - if (isLoading || !data) return

Lädt…

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

Lädt…

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

Lädt…

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

Lädt…

; + const query = useQuery({ queryKey: ["statistics", "feeding"], queryFn: fetchFeedingStats }); return ( -
- - - Futterverbrauch pro Volk - - - b - a) - .slice(0, 10) - .map(([colonyId, value]) => ({ - label: colonyLabels.get(colonyId) ?? colonyId, - value, - }))} - emptyText="Noch keine Fütterungen erfasst." - /> - - - - - Futterverbrauch pro Saison - - - ({ - label: year, - value, - }))} - emptyText="Noch keine Fütterungen erfasst." - /> - - -
+ + {(data) => ( +
+ + Summe der eingetragenen Futtermengen in Liter bzw. kg, so wie sie bei der Fütterung + erfasst wurden. + + + + Futterverbrauch pro Volk + + + 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." + /> + + + + + Futterverbrauch pro Saison + + + ({ + label: year, + value, + unit: FEED_UNIT, + }))} + formatValue={kg} + emptyText="Noch keine Fütterungen erfasst." + /> + + +
+ )} +
+ ); +} + +function TabPicker({ value, onChange }: { value: StatsTab; onChange: (value: StatsTab) => void }) { + const current = TABS.find((tab) => tab.value === value); + return ( + ); } export default function StatisticsPage() { + const [tab, setTab] = useState("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() { - - - Völker - Königinnen - Bienenstände - Varroa - Honig - Futter + setTab(next as StatsTab)}> + + + {TABS.map((entry) => ( + + {entry.label} + + ))} diff --git a/ApiCore-Frontend/src/app/(app)/storage/create-stock-item-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/create-stock-item-dialog.tsx index 5d6e1c8..09958aa 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/create-stock-item-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/create-stock-item-dialog.tsx @@ -61,7 +61,7 @@ export function CreateStockItemDialog() { return ( - + Neuer Artikel} /> + + Neuer Artikel} /> Neuer Lagerartikel diff --git a/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx index 6d4a667..c02be36 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/create-storage-container-dialog.tsx @@ -53,7 +53,7 @@ export function CreateStorageContainerDialog() { return ( - + Neuer Behälter} /> + + Neuer Behälter} /> Neuer Lagerbehälter diff --git a/ApiCore-Frontend/src/app/(app)/storage/edit-stock-item-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/edit-stock-item-dialog.tsx index a746531..9cd9785 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/edit-stock-item-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/edit-stock-item-dialog.tsx @@ -64,8 +64,8 @@ export function EditStockItemDialog({ item }: { item: StockItem }) { - + } diff --git a/ApiCore-Frontend/src/app/(app)/storage/edit-storage-container-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/edit-storage-container-dialog.tsx index 1b797a5..5c47d30 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/edit-storage-container-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/edit-storage-container-dialog.tsx @@ -50,8 +50,8 @@ export function EditStorageContainerDialog({ container }: { container: StorageCo - + } diff --git a/ApiCore-Frontend/src/app/(app)/storage/page.tsx b/ApiCore-Frontend/src/app/(app)/storage/page.tsx index 525592c..1980e6f 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/page.tsx @@ -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("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() { } /> @@ -55,86 +54,131 @@ export default function StoragePage() { historyCount={historyContainers.length} /> - {containersLoading ?

Lädt…

: null} - {containers && visibleContainers.length === 0 ? ( - - ) : null} - - {visibleContainers.length > 0 ? ( -
- {visibleContainers.map((container) => ( - {container.code}} - subtitle={container.containerType ?? "—"} - badge={ - - {STORAGE_CONTAINER_STATUS_LABELS[container.status]} - - } - fields={[{ label: "Standort", value: container.location ?? "—" }]} - actions={ - <> - - - - } - /> - ))} -
- ) : null} + + {() => + visibleContainers.length === 0 ? ( + + ) : ( + + ) + } + -
-
-

Lagerartikel

+
+
+
+

Lagerartikel

+

+ Gläser, Deckel, Futter und anderes Material mit Bestand. +

+
- {itemsLoading ?

Lädt…

: null} - {stockItems && stockItems.length === 0 ? ( - - ) : null} - - {stockItems && stockItems.length > 0 ? ( -
- {stockItems.map((item) => ( - Niedrig - ) : undefined - } - fields={[{ label: "Bestand", value: `${item.quantity} ${item.unit}` }]} - actions={ - <> - - - - } - /> - ))} -
- ) : null} + } + /> + } + > + {(stockItems) => } +
); } + +function ContainersEmpty({ tab }: { tab: EntityLifecycleTab }) { + if (tab === "history") { + return ( + + ); + } + return ( + } + /> + ); +} + +function ContainerGrid({ containers }: { containers: StorageContainer[] }) { + return ( +
+ {containers.map((container) => ( + {container.code}} + subtitle={container.containerType ?? "—"} + badge={ + + {STORAGE_CONTAINER_STATUS_LABELS[container.status]} + + } + fields={[{ label: "Standort", value: container.location ?? "—" }]} + actions={ + <> + + + + } + /> + ))} +
+ ); +} + +function isLowStock(item: StockItem): boolean { + return item.minThreshold !== null && item.quantity < item.minThreshold; +} + +function StockItemGrid({ stockItems }: { stockItems: StockItem[] }) { + return ( +
+ {stockItems.map((item) => ( + + + Bestand niedrig + + ) : undefined + } + fields={[ + { label: "Bestand", value: `${item.quantity} ${item.unit}` }, + { + label: "Mindestbestand", + value: item.minThreshold !== null ? `${item.minThreshold} ${item.unit}` : "—", + }, + ]} + actions={ + <> + + + + } + /> + ))} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx b/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx index f98739b..1005af3 100644 --- a/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/storage/record-movement-dialog.tsx @@ -60,7 +60,7 @@ export function RecordMovementDialog({ stockItemId }: { stockItemId: string }) { return ( - Bewegung buchen} /> + Bewegung buchen} /> Bestandsbewegung diff --git a/ApiCore-Frontend/src/app/(app)/winter/page.tsx b/ApiCore-Frontend/src/app/(app)/winter/page.tsx index 68377aa..768aa49 100644 --- a/ApiCore-Frontend/src/app/(app)/winter/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/winter/page.tsx @@ -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 ; +} + +function SeamsHint() { + return ( +

+ + + Gassen = besetzte Wabengassen, also die + Zwischenräume zwischen den Waben, in denen Bienen sitzen. Sie zeigen die Volksstärke im Winter. + +

+ ); +} + 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 (
{row.autumn ? ( - ) : row.isLiving && canCreate ? ( @@ -99,16 +139,9 @@ function RowActions({ Nicht eingewintert )} {row.spring?.outcome ? ( - + } /> ) : ( groups.map((group) => ( diff --git a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx index 670a1fb..1c34496 100644 --- a/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/winter/winter-check-dialog.tsx @@ -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 ? : null} {WINTER_OUTCOME_LABELS[value]} ))} @@ -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)} /> +

+ Zwischenräume zwischen den Waben, in denen Bienen sitzen. Mehr Gassen = stärkeres + Volk. +

{!isSpring ? (
diff --git a/ApiCore-Frontend/src/components/qr-code-dialog.tsx b/ApiCore-Frontend/src/components/qr-code-dialog.tsx index 32a1a37..fe97813 100644 --- a/ApiCore-Frontend/src/components/qr-code-dialog.tsx +++ b/ApiCore-Frontend/src/components/qr-code-dialog.tsx @@ -56,7 +56,7 @@ export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialog + @@ -79,7 +79,7 @@ export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialog {nfcSupported ? (