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
+476 -142

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}
{apiaries && located.length === 0 ? (
<EmptyState <EmptyState
icon={MapPin} icon={MapPin}
title="Keine Koordinaten hinterlegt" title="Noch kein Bienenstand auf der Karte"
description="Trage Breiten- und Längengrad bei einem Bienenstand ein, um ihn hier zu sehen." 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} ) : null}
<ApiaryMap apiaries={located} />
{located.length > 0 ? <ApiaryMap apiaries={located} /> : null} </>
);
}}
</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("");
});
});
+48 -16
View File
@@ -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}
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>
);
}
{results && results.length > 0 ? ( function SearchResults({ results }: { results: SearchResult[] }) {
return (
<Card className="max-w-lg"> <Card className="max-w-lg">
<CardHeader> <CardHeader>
<CardTitle>Ergebnisse</CardTitle> <CardTitle>
{results.length} Treffer
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
{results.map((result) => ( {results.map((result) => (
<Link <Link
key={`${result.type}-${result.id}`} key={`${result.type}-${result.id}`}
href={resultHref(result)} href={resultHref(result)}
className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-muted" className="flex min-h-11 items-center justify-between gap-3 rounded-md border p-3 text-sm hover:bg-muted"
> >
<span>{result.label}</span> <span className="min-w-0 truncate">{result.label}</span>
<Badge variant="secondary">{TYPE_LABELS[result.type]}</Badge> <Badge variant="secondary">{TYPE_LABELS[result.type]}</Badge>
</Link> </Link>
))} ))}
</CardContent> </CardContent>
</Card> </Card>
) : null}
</main>
); );
} }
@@ -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,12 +70,21 @@ 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 (
<QueryState query={query} what="Völkerzahlen">
{(data) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card> <Card>
<CardHeader> <CardHeader>
@@ -64,6 +96,7 @@ function ColonyStatsPanel() {
label: COLONY_STATUS_LABELS[row.status] ?? row.status, label: COLONY_STATUS_LABELS[row.status] ?? row.status,
value: row.count, value: row.count,
}))} }))}
emptyText="Noch keine Völker angelegt."
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -82,47 +115,58 @@ function ColonyStatsPanel() {
</CardContent> </CardContent>
</Card> </Card>
</div> </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 (
<QueryState query={query} what="Königinnenzahlen">
{(data) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<KpiTile label="Aktive Königinnen" value={String(data.totalActive)} /> <KpiTile label="Aktive Königinnen" value={String(data.totalActive)} />
<KpiTile <KpiTile
label="Ø Lebensdauer (Tage)" label="Durchschnittliche Lebensdauer"
value={data.averageLifespanDays !== null ? Math.round(data.averageLifespanDays).toString() : "–"} value={
data.averageLifespanDays !== null
? `${Math.round(data.averageLifespanDays)} Tage`
: "–"
}
/> />
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Honigertrag pro Königin (Ø-Bewertung)</CardTitle> <CardTitle>Honigertrag pro Königin</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-col gap-3">
<PanelHint>
Durchschnitt deiner Bewertungen auf einer Skala von 1 (schwach) bis 5 (sehr gut).
</PanelHint>
<StatBarList <StatBarList
items={data.evaluationAverages items={data.evaluationAverages
.slice(0, 10) .slice(0, 10)
.map((row) => ({ label: row.code, value: row.honeyYield }))} .map((row) => ({ label: row.code, value: row.honeyYield, unit: "von 5" }))}
emptyText="Noch keine Königinnenbewertungen erfasst." emptyText="Noch keine Königinnenbewertungen erfasst."
formatValue={(value) => value.toFixed(1)} formatValue={(value) => value.toLocaleString("de-DE", { maximumFractionDigits: 1 })}
/> />
</CardContent> </CardContent>
</Card> </Card>
</div> </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 (
<QueryState query={query} what="Standortzahlen">
{(data) => (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Honigertrag pro Bienenstand</CardTitle> <CardTitle>Honigertrag pro Bienenstand</CardTitle>
@@ -138,15 +182,17 @@ function ApiaryStatsPanel() {
/> />
</CardContent> </CardContent>
</Card> </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 (
<QueryState query={query} what="Varroadaten">
{(data) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card> <Card>
<CardHeader> <CardHeader>
@@ -161,6 +207,7 @@ function VarroaStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string>
.map((row) => ({ .map((row) => ({
label: colonyLabels.get(row.colonyId) ?? row.colonyId, label: colonyLabels.get(row.colonyId) ?? row.colonyId,
value: row.treatmentCount, value: row.treatmentCount,
unit: row.treatmentCount === 1 ? "Behandlung" : "Behandlungen",
}))} }))}
emptyText="Noch keine Behandlungen erfasst." emptyText="Noch keine Behandlungen erfasst."
/> />
@@ -185,15 +232,17 @@ function VarroaStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string>
</CardContent> </CardContent>
</Card> </Card>
</div> </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 (
<QueryState query={query} what="Honigzahlen">
{(data) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<KpiTile label="Erntemenge gesamt" value={`${kg(data.totalHarvestKg)} kg`} /> <KpiTile label="Erntemenge gesamt" value={`${kg(data.totalHarvestKg)} kg`} />
@@ -234,16 +283,22 @@ function HoneyStatsPanel({ apiaryLabels }: { apiaryLabels: Map<string, string> }
</CardContent> </CardContent>
</Card> </Card>
</div> </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 (
<QueryState query={query} what="Futterdaten">
{(data) => (
<div className="flex flex-col gap-4"> <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> <Card>
<CardHeader> <CardHeader>
<CardTitle>Futterverbrauch pro Volk</CardTitle> <CardTitle>Futterverbrauch pro Volk</CardTitle>
@@ -256,7 +311,9 @@ function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string>
.map(([colonyId, value]) => ({ .map(([colonyId, value]) => ({
label: colonyLabels.get(colonyId) ?? colonyId, label: colonyLabels.get(colonyId) ?? colonyId,
value, value,
unit: FEED_UNIT,
}))} }))}
formatValue={kg}
emptyText="Noch keine Fütterungen erfasst." emptyText="Noch keine Fütterungen erfasst."
/> />
</CardContent> </CardContent>
@@ -270,16 +327,39 @@ function FeedingStatsPanel({ colonyLabels }: { colonyLabels: Map<string, string>
items={sortedRecordEntries(data.bySeason).map(([year, value]) => ({ items={sortedRecordEntries(data.bySeason).map(([year, value]) => ({
label: year, label: year,
value, value,
unit: FEED_UNIT,
}))} }))}
formatValue={kg}
emptyText="Noch keine Fütterungen erfasst." emptyText="Noch keine Fütterungen erfasst."
/> />
</CardContent> </CardContent>
</Card> </Card>
</div> </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>
} }
+87 -43
View File
@@ -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,22 +54,70 @@ 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 ? ( {() =>
visibleContainers.length === 0 ? (
<ContainersEmpty tab={containerTab} />
) : (
<ContainerGrid containers={visibleContainers} />
)
}
</QueryState>
</section>
<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>
<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 <EmptyState
icon={Warehouse} icon={Warehouse}
title={containerTab === "active" ? "Noch keine Lagerbehälter" : "Keine Historie"} title="Keine Historie"
description={ description="Außer Betrieb genommene Behälter erscheinen hier."
containerTab === "active"
? "Lege deinen ersten Behälter an."
: "Außer Betrieb genommene Behälter erscheinen hier."
}
/> />
) : null} );
}
return (
<EmptyState
icon={Warehouse}
title="Noch keine Lagerbehälter"
description="Lege deinen ersten Behälter an, z. B. einen Hobbock oder Eimer."
action={<CreateStorageContainerDialog />}
/>
);
}
{visibleContainers.length > 0 ? ( function ContainerGrid({ containers }: { containers: StorageContainer[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleContainers.map((container) => ( {containers.map((container) => (
<EntityCard <EntityCard
key={container.id} key={container.id}
icon={Warehouse} icon={Warehouse}
@@ -92,24 +139,15 @@ export default function StoragePage() {
/> />
))} ))}
</div> </div>
) : null} );
</section> }
<section className="flex flex-col gap-4"> function isLowStock(item: StockItem): boolean {
<div className="flex items-center justify-between"> return item.minThreshold !== null && item.quantity < item.minThreshold;
<h2 className="text-lg font-semibold tracking-tight">Lagerartikel</h2> }
<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 ? ( function StockItemGrid({ stockItems }: { stockItems: StockItem[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{stockItems.map((item) => ( {stockItems.map((item) => (
<EntityCard <EntityCard
@@ -119,11 +157,20 @@ export default function StoragePage() {
title={item.name} title={item.name}
subtitle={STOCK_CATEGORY_LABELS[item.category]} subtitle={STOCK_CATEGORY_LABELS[item.category]}
badge={ badge={
item.minThreshold !== null && item.quantity < item.minThreshold ? ( isLowStock(item) ? (
<Badge variant="destructive">Niedrig</Badge> <Badge variant="warning">
<AlertTriangle aria-hidden />
Bestand niedrig
</Badge>
) : undefined ) : undefined
} }
fields={[{ label: "Bestand", value: `${item.quantity} ${item.unit}` }]} fields={[
{ label: "Bestand", value: `${item.quantity} ${item.unit}` },
{
label: "Mindestbestand",
value: item.minThreshold !== null ? `${item.minThreshold} ${item.unit}` : "—",
},
]}
actions={ actions={
<> <>
<EditStockItemDialog item={item} /> <EditStockItemDialog item={item} />
@@ -133,8 +180,5 @@ export default function StoragePage() {
/> />
))} ))}
</div> </div>
) : null}
</section>
</main>
); );
} }
@@ -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>
+75 -22
View File
@@ -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}
> >