feat(ui): list pages with retry on errors, a next step when empty, one main action

Lists load through QueryState: a failed load says so and offers
"Erneut versuchen" instead of looking empty; empty lists link to the
create page; exports move into a "Mehr" menu so "Neu …" is the one
primary action.

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:14:07 +02:00
1 parent 62e78498bf
commit 9944aabbc3
9 files changed
+623 -428

No files matched your search

+105 -45
View File
@@ -2,27 +2,35 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { MapPin } from "lucide-react"; import { ChevronDown, MapPin } 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 { Apiary } from "@/lib/apiaries/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
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 { 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function ApiariesPage() { export default function ApiariesPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active"); const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const { data: apiaries, isLoading, isError } = useQuery({ const apiariesQuery = useQuery({
queryKey: ["apiaries"], queryKey: ["apiaries"],
queryFn: () => fetchApiaries({}), queryFn: () => fetchApiaries({}),
}); });
const byLocation = (apiaries ?? []).filter((apiary) => const byLocation = (apiariesQuery.data ?? []).filter((apiary) =>
location ? (apiary.location ?? "").toLowerCase().includes(location.toLowerCase()) : true, location ? (apiary.location ?? "").toLowerCase().includes(location.toLowerCase()) : true,
); );
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE"); const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
@@ -34,13 +42,21 @@ export default function ApiariesPage() {
<PageHeader <PageHeader
icon={MapPin} icon={MapPin}
title="Bienenstände" title="Bienenstände"
description="Standorte deiner Völker" description="Hier verwaltest du die Standorte deiner Völker."
actions={ actions={
<> <>
<Button variant="outline" nativeButton={false} render={<Link href="/apiaries/map" />}> <DropdownMenu>
Karte <DropdownMenuTrigger render={<Button variant="outline" size="touch" />}>
</Button> Mehr
<Button nativeButton={false} render={<Link href="/apiaries/new" />}> <ChevronDown aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto min-w-48">
<DropdownMenuItem className="min-h-11" render={<Link href="/apiaries/map" />}>
Kartenansicht
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button size="touch" nativeButton={false} render={<Link href="/apiaries/new" />}>
+ Neuer Bienenstand + Neuer Bienenstand
</Button> </Button>
</> </>
@@ -57,49 +73,93 @@ export default function ApiariesPage() {
<Input <Input
className="w-48" className="w-48"
placeholder="Standort" placeholder="Standort"
aria-label="Nach Standort filtern"
value={location} value={location}
onChange={(event) => setLocation(event.target.value)} onChange={(event) => setLocation(event.target.value)}
/> />
</div> </div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState query={apiariesQuery} what="Bienenstände">
{isError ? ( {() =>
<p role="alert" className="text-sm text-destructive"> visibleApiaries.length === 0 ? (
Bienenstände konnten nicht geladen werden. <ApiariesEmpty
</p> tab={tab}
) : null} isFiltered={location !== ""}
onResetFilter={() => setLocation("")}
{apiaries && visibleApiaries.length === 0 ? (
<EmptyState
icon={MapPin}
title={tab === "active" ? "Noch keine Bienenstände" : "Keine Historie"}
description={
tab === "active"
? "Lege deinen ersten Bienenstand an, um Völker zuzuordnen."
: "Deaktivierte Bienenstände erscheinen hier."
}
/>
) : null}
{visibleApiaries.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleApiaries.map((apiary) => (
<EntityCard
key={apiary.id}
href={`/apiaries/${apiary.id}`}
icon={MapPin}
title={apiary.name}
subtitle={apiary.location ?? "—"}
badge={
<Badge variant={apiary.status === "ACTIVE" ? "default" : "secondary"}>
{apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
</Badge>
}
fields={[{ label: "Adresse", value: apiary.address ?? "—" }]}
/> />
))} ) : (
</div> <ApiaryGrid apiaries={visibleApiaries} />
) : null} )
}
</QueryState>
</main> </main>
); );
} }
function ApiariesEmpty({
tab,
isFiltered,
onResetFilter,
}: {
tab: EntityLifecycleTab;
isFiltered: boolean;
onResetFilter: () => void;
}) {
if (isFiltered) {
return (
<EmptyState
icon={MapPin}
title="Kein Bienenstand an diesem Standort"
description="Der Standort-Filter blendet alle Bienenstände aus."
action={
<Button size="touch" variant="outline" onClick={onResetFilter}>
Filter zurücksetzen
</Button>
}
/>
);
}
if (tab === "history") {
return (
<EmptyState
icon={MapPin}
title="Keine Historie"
description="Deaktivierte Bienenstände erscheinen hier."
/>
);
}
return (
<EmptyState
icon={MapPin}
title="Noch keine Bienenstände"
description="Lege deinen ersten Bienenstand an, um Völker zuzuordnen."
action={
<Button size="touch" nativeButton={false} render={<Link href="/apiaries/new" />}>
Ersten Bienenstand anlegen
</Button>
}
/>
);
}
function ApiaryGrid({ apiaries }: { apiaries: Apiary[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{apiaries.map((apiary) => (
<EntityCard
key={apiary.id}
href={`/apiaries/${apiary.id}`}
icon={MapPin}
title={apiary.name}
subtitle={apiary.location ?? "—"}
badge={
<Badge variant={apiary.status === "ACTIVE" ? "default" : "secondary"}>
{apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
</Badge>
}
fields={[{ label: "Adresse", value: apiary.address ?? "—" }]}
/>
))}
</div>
);
}
@@ -8,13 +8,14 @@ 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 { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE"); return new Date(iso).toLocaleDateString("de-DE");
} }
export default function BottlingBatchesPage() { export default function BottlingBatchesPage() {
const { data: bottlingBatches, isLoading, isError } = useQuery({ const bottlingQuery = useQuery({
queryKey: ["bottling-batches"], queryKey: ["bottling-batches"],
queryFn: () => fetchBottlingBatches(), queryFn: () => fetchBottlingBatches(),
}); });
@@ -24,49 +25,51 @@ export default function BottlingBatchesPage() {
<PageHeader <PageHeader
icon={Package} icon={Package}
title="Abfüllungen" title="Abfüllungen"
description="Archiv aller abgefüllten Chargen" description="Hier findest du alle abgefüllten Chargen und erfasst neue Abfüllungen."
actions={ actions={
<Button nativeButton={false} render={<Link href="/bottling/new" />}> <Button size="touch" nativeButton={false} render={<Link href="/bottling/new" />}>
+ Neue Abfüllung + Neue Abfüllung
</Button> </Button>
} }
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState
{isError ? ( query={bottlingQuery}
<p role="alert" className="text-sm text-destructive"> what="Abfüllungen"
Abfüllungen konnten nicht geladen werden. empty={
</p> <EmptyState
) : null} icon={Package}
title="Noch keine Abfüllungen"
{bottlingBatches && bottlingBatches.length === 0 ? ( description="Lege eine Abfüllung an, sobald du Honig in Gläser gefüllt hast."
<EmptyState action={
icon={Package} <Button size="touch" nativeButton={false} render={<Link href="/bottling/new" />}>
title="Noch keine Abfüllungen" Erste Abfüllung anlegen
description="Lege eine Abfüllung an, sobald du Honig in Gläser gefüllt hast." </Button>
/> }
) : null} />
}
{bottlingBatches && bottlingBatches.length > 0 ? ( >
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> {(bottlingBatches) => (
{bottlingBatches.map((bottlingBatch) => ( <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<EntityCard {bottlingBatches.map((bottlingBatch) => (
key={bottlingBatch.id} <EntityCard
href={`/bottling/${bottlingBatch.id}`} key={bottlingBatch.id}
icon={Package} href={`/bottling/${bottlingBatch.id}`}
title={<span className="font-mono">{bottlingBatch.lotNumber}</span>} icon={Package}
subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber} title={<span className="font-mono">{bottlingBatch.lotNumber}</span>}
fields={[ subtitle={bottlingBatch.product?.name ?? bottlingBatch.honeyBatch.batchNumber}
{ fields={[
label: "Menge", {
value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`, label: "Menge",
}, value: `${bottlingBatch.jarCount} × ${bottlingBatch.jarSize} g`,
{ label: "Datum", value: formatDate(bottlingBatch.date) }, },
]} { label: "Datum", value: formatDate(bottlingBatch.date) },
/> ]}
))} />
</div> ))}
) : null} </div>
)}
</QueryState>
</main> </main>
); );
} }
+115 -58
View File
@@ -2,11 +2,11 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Layers } from "lucide-react"; import { ChevronDown, Layers } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { ColonyStatus } from "@/lib/colonies/types"; import { Colony, ColonyStatus } from "@/lib/colonies/types";
import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { exportColoniesCsv } from "@/lib/export/api"; import { exportColoniesCsv } from "@/lib/export/api";
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
@@ -15,7 +15,14 @@ import { Badge } from "@/components/ui/badge";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -31,7 +38,7 @@ export default function ColoniesPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active"); const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [status, setStatus] = useState<ColonyStatus | "">(""); const [status, setStatus] = useState<ColonyStatus | "">("");
const { data: colonies, isLoading, isError } = useQuery({ const coloniesQuery = useQuery({
queryKey: ["colonies"], queryKey: ["colonies"],
queryFn: () => fetchColonies({}), queryFn: () => fetchColonies({}),
}); });
@@ -41,8 +48,9 @@ export default function ColoniesPage() {
onError: () => toast.error("CSV-Export konnte nicht heruntergeladen werden"), onError: () => toast.error("CSV-Export konnte nicht heruntergeladen werden"),
}); });
const activeColonies = (colonies ?? []).filter((colony) => colony.deactivatedAt === null); const colonies = coloniesQuery.data ?? [];
const historyColonies = (colonies ?? []).filter((colony) => colony.deactivatedAt !== null); const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null);
const bucketColonies = tab === "active" ? activeColonies : historyColonies; const bucketColonies = tab === "active" ? activeColonies : historyColonies;
const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies; const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies;
const statusOptions = tab === "active" ? ACTIVE_STATUS_OPTIONS : HISTORY_STATUS_OPTIONS; const statusOptions = tab === "active" ? ACTIVE_STATUS_OPTIONS : HISTORY_STATUS_OPTIONS;
@@ -52,17 +60,25 @@ export default function ColoniesPage() {
<PageHeader <PageHeader
icon={Layers} icon={Layers}
title="Völker" title="Völker"
description="Alle Bienenvölker im Überblick" description="Hier siehst du alle Bienenvölker und legst neue an."
actions={ actions={
<> <>
<Button <DropdownMenu>
variant="outline" <DropdownMenuTrigger render={<Button variant="outline" size="touch" />}>
disabled={exportMutation.isPending} Mehr
onClick={() => exportMutation.mutate()} <ChevronDown aria-hidden />
> </DropdownMenuTrigger>
CSV exportieren <DropdownMenuContent align="end" className="w-auto min-w-48">
</Button> <DropdownMenuItem
<Button nativeButton={false} render={<Link href="/colonies/new" />}> className="min-h-11"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate()}
>
CSV exportieren
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button size="touch" nativeButton={false} render={<Link href="/colonies/new" />}>
+ Neues Volk + Neues Volk
</Button> </Button>
</> </>
@@ -83,7 +99,7 @@ export default function ColoniesPage() {
value={status || "ALL"} value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ColonyStatus))} onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ColonyStatus))}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40" aria-label="Nach Status filtern">
<SelectValue placeholder="Status"> <SelectValue placeholder="Status">
{status ? COLONY_STATUS_LABELS[status] : "Alle Status"} {status ? COLONY_STATUS_LABELS[status] : "Alle Status"}
</SelectValue> </SelectValue>
@@ -99,50 +115,91 @@ export default function ColoniesPage() {
</Select> </Select>
</div> </div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState query={coloniesQuery} what="Völker">
{isError ? ( {() =>
<p role="alert" className="text-sm text-destructive"> visibleColonies.length === 0 ? (
Völker konnten nicht geladen werden. <ColoniesEmpty
</p> tab={tab}
) : null} isFiltered={status !== "" && bucketColonies.length > 0}
onResetFilter={() => setStatus("")}
{colonies && visibleColonies.length === 0 ? (
<EmptyState
icon={Layers}
title={tab === "active" ? "Noch keine Völker" : "Keine Historie"}
description={
tab === "active"
? "Lege dein erstes Volk an, um den Überblick zu behalten."
: "Aufgelöste, verkaufte oder verstorbene Völker erscheinen hier."
}
/>
) : null}
{visibleColonies.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleColonies.map((colony) => (
<EntityCard
key={colony.id}
href={`/colonies/${colony.id}`}
icon={Layers}
title={<span className="font-mono">{colony.code}</span>}
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
badge={
<Badge variant={statusTone(colony.status)}>
{COLONY_STATUS_LABELS[colony.status]}
</Badge>
}
fields={[
{ label: "Stärke", value: colony.strength ?? "—" },
{
label: "Gesundheit",
value: colony.healthStatus ? COLONY_CONDITION_LABELS[colony.healthStatus] : "—",
},
]}
/> />
))} ) : (
</div> <ColonyGrid colonies={visibleColonies} />
) : null} )
}
</QueryState>
</main> </main>
); );
} }
function ColoniesEmpty({
tab,
isFiltered,
onResetFilter,
}: {
tab: EntityLifecycleTab;
isFiltered: boolean;
onResetFilter: () => void;
}) {
if (isFiltered) {
return (
<EmptyState
icon={Layers}
title="Kein Volk mit diesem Status"
description="Der Filter blendet alle Völker aus."
action={
<Button size="touch" variant="outline" onClick={onResetFilter}>
Filter zurücksetzen
</Button>
}
/>
);
}
if (tab === "history") {
return (
<EmptyState
icon={Layers}
title="Keine Historie"
description="Aufgelöste, verkaufte oder verstorbene Völker erscheinen hier."
/>
);
}
return (
<EmptyState
icon={Layers}
title="Noch keine Völker"
description="Lege dein erstes Volk an, um den Überblick zu behalten."
action={
<Button size="touch" nativeButton={false} render={<Link href="/colonies/new" />}>
Erstes Volk anlegen
</Button>
}
/>
);
}
function ColonyGrid({ colonies }: { colonies: Colony[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{colonies.map((colony) => (
<EntityCard
key={colony.id}
href={`/colonies/${colony.id}`}
icon={Layers}
title={<span className="font-mono">{colony.code}</span>}
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
badge={
<Badge variant={statusTone(colony.status)}>{COLONY_STATUS_LABELS[colony.status]}</Badge>
}
fields={[
{ label: "Stärke", value: colony.strength ?? "—" },
{
label: "Gesundheit",
value: colony.healthStatus ? COLONY_CONDITION_LABELS[colony.healthStatus] : "—",
},
]}
/>
))}
</div>
);
}
@@ -11,9 +11,10 @@ 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 { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
export default function EquipmentPage() { export default function EquipmentPage() {
const { data: equipmentList, isLoading, isError } = useQuery({ const equipmentQuery = useQuery({
queryKey: ["equipment"], queryKey: ["equipment"],
queryFn: () => fetchEquipmentList(), queryFn: () => fetchEquipmentList(),
}); });
@@ -23,50 +24,52 @@ export default function EquipmentPage() {
<PageHeader <PageHeader
icon={Wrench} icon={Wrench}
title="Inventar" title="Inventar"
description="Ausrüstung und Zustand im Blick" description="Hier erfasst du deine Ausrüstung und ihren Zustand."
gradient="var(--gradient-sage)" gradient="var(--gradient-sage)"
actions={ actions={
<Button nativeButton={false} render={<Link href="/equipment/new" />}> <Button size="touch" nativeButton={false} render={<Link href="/equipment/new" />}>
+ Neuer Gegenstand + Neuer Gegenstand
</Button> </Button>
} }
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState
{isError ? ( query={equipmentQuery}
<p role="alert" className="text-sm text-destructive"> what="Inventar-Einträge"
Inventar konnte nicht geladen werden. empty={
</p> <EmptyState
) : null} icon={Wrench}
title="Noch kein Inventar"
{equipmentList && equipmentList.length === 0 ? ( description="Erfasse deinen ersten Gegenstand, um den Zustand im Blick zu behalten."
<EmptyState action={
icon={Wrench} <Button size="touch" nativeButton={false} render={<Link href="/equipment/new" />}>
title="Noch kein Inventar" Ersten Gegenstand erfassen
description="Erfasse deinen ersten Gegenstand, um den Zustand im Blick zu behalten." </Button>
/> }
) : null} />
}
{equipmentList && equipmentList.length > 0 ? ( >
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> {(equipmentList) => (
{equipmentList.map((item) => ( <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<EntityCard {equipmentList.map((item) => (
key={item.id} <EntityCard
href={`/equipment/${item.id}`} key={item.id}
icon={Wrench} href={`/equipment/${item.id}`}
gradient="var(--gradient-sage)" icon={Wrench}
title={item.name} gradient="var(--gradient-sage)"
subtitle={item.inventoryNumber} title={item.name}
badge={ subtitle={item.inventoryNumber}
<Badge variant={statusTone(item.condition)}> badge={
{EQUIPMENT_CONDITION_LABELS[item.condition]} <Badge variant={statusTone(item.condition)}>
</Badge> {EQUIPMENT_CONDITION_LABELS[item.condition]}
} </Badge>
fields={[{ label: "Standort", value: item.location ?? "—" }]} }
/> fields={[{ label: "Standort", value: item.location ?? "—" }]}
))} />
</div> ))}
) : null} </div>
)}
</QueryState>
</main> </main>
); );
} }
@@ -8,70 +8,67 @@ 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 { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
export default function ExtractionsPage() { export default function ExtractionsPage() {
const { const extractionsQuery = useQuery({ queryKey: ["extractions"], queryFn: fetchExtractions });
data: extractions,
isLoading,
isError,
} = useQuery({ queryKey: ["extractions"], queryFn: fetchExtractions });
const sortedExtractions = [...(extractions ?? [])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
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={FlaskConical} icon={FlaskConical}
title="Schleuderungen" title="Schleuderungen"
description="Erfasste Schleuderungen aus einer oder mehreren Ernten" description="Hier erfasst du Schleuderungen aus einer oder mehreren Ernten."
actions={ actions={
<Button nativeButton={false} render={<Link href="/extractions/new" />}> <Button size="touch" nativeButton={false} render={<Link href="/extractions/new" />}>
+ Neue Schleuderung + Neue Schleuderung
</Button> </Button>
} }
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState
{isError ? ( query={extractionsQuery}
<p role="alert" className="text-sm text-destructive"> what="Schleuderungen"
Schleuderungen konnten nicht geladen werden. empty={
</p> <EmptyState
) : null} icon={FlaskConical}
title="Noch keine Schleuderungen"
{extractions && sortedExtractions.length === 0 ? ( description="Erfasse deine erste Schleuderung aus einer oder mehreren Ernten."
<EmptyState action={
icon={FlaskConical} <Button size="touch" nativeButton={false} render={<Link href="/extractions/new" />}>
title="Noch keine Schleuderungen" Erste Schleuderung erfassen
description="Erfasse deine erste Schleuderung aus einer oder mehreren Ernten." </Button>
/> }
) : null} />
}
{sortedExtractions.length > 0 ? ( >
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> {(extractions) => (
{sortedExtractions.map((extraction) => ( <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<EntityCard {[...extractions]
key={extraction.id} .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
href={`/extractions/${extraction.id}`} .map((extraction) => (
icon={FlaskConical} <EntityCard
title={new Date(extraction.date).toLocaleDateString("de-DE")} key={extraction.id}
subtitle={extraction.room ?? "—"} href={`/extractions/${extraction.id}`}
fields={[ icon={FlaskConical}
{ title={new Date(extraction.date).toLocaleDateString("de-DE")}
label: "Menge", subtitle={extraction.room ?? "—"}
value: extraction.amount !== null ? `${extraction.amount} kg` : "—", fields={[
}, {
{ label: "Menge",
label: "Wassergehalt", value: extraction.amount !== null ? `${extraction.amount} kg` : "—",
value: extraction.waterContent !== null ? `${extraction.waterContent} %` : "—", },
}, {
{ label: "Ernten", value: extraction.harvests.length }, label: "Wassergehalt",
]} value: extraction.waterContent !== null ? `${extraction.waterContent} %` : "—",
/> },
))} { label: "Ernten", value: extraction.harvests.length },
</div> ]}
) : null} />
))}
</div>
)}
</QueryState>
</main> </main>
); );
} }
@@ -9,69 +9,68 @@ 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 { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { QueryState } from "@/components/query-state";
export default function HarvestsPage() { export default function HarvestsPage() {
const { const harvestsQuery = useQuery({ queryKey: ["harvests"], queryFn: fetchHarvests });
data: harvests, // Only used for apiary names; without it the cards fall back to "—".
isLoading,
isError,
} = useQuery({ queryKey: ["harvests"], queryFn: fetchHarvests });
const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() }); const { data: apiaries } = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
const apiaryNameById = new Map((apiaries ?? []).map((apiary) => [apiary.id, apiary.name])); const apiaryNameById = new Map((apiaries ?? []).map((apiary) => [apiary.id, apiary.name]));
const sortedHarvests = [...(harvests ?? [])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
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={Scissors} icon={Scissors}
title="Ernten" title="Ernten"
description="Erfasste Ernten je Bienenstand" description="Hier erfasst du deine Ernten je Bienenstand."
actions={ actions={
<Button nativeButton={false} render={<Link href="/harvests/new" />}> <Button size="touch" nativeButton={false} render={<Link href="/harvests/new" />}>
+ Neue Ernte + Neue Ernte
</Button> </Button>
} }
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState
{isError ? ( query={harvestsQuery}
<p role="alert" className="text-sm text-destructive"> what="Ernten"
Ernten konnten nicht geladen werden. empty={
</p> <EmptyState
) : null} icon={Scissors}
title="Noch keine Ernten"
{harvests && sortedHarvests.length === 0 ? ( description="Erfasse deine erste Ernte für einen Bienenstand."
<EmptyState action={
icon={Scissors} <Button size="touch" nativeButton={false} render={<Link href="/harvests/new" />}>
title="Noch keine Ernten" Erste Ernte erfassen
description="Erfasse deine erste Ernte für einen Bienenstand." </Button>
/> }
) : null} />
}
{sortedHarvests.length > 0 ? ( >
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> {(harvests) => (
{sortedHarvests.map((harvest) => ( <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<EntityCard {[...harvests]
key={harvest.id} .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
href={`/harvests/${harvest.id}`} .map((harvest) => (
icon={Scissors} <EntityCard
title={new Date(harvest.date).toLocaleDateString("de-DE")} key={harvest.id}
subtitle={apiaryNameById.get(harvest.apiaryId) ?? "—"} href={`/harvests/${harvest.id}`}
fields={[ icon={Scissors}
{ label: "Honigsorte", value: harvest.honeyType ?? "—" }, title={new Date(harvest.date).toLocaleDateString("de-DE")}
{ subtitle={apiaryNameById.get(harvest.apiaryId) ?? "—"}
label: "Nettogewicht", fields={[
value: harvest.netWeight !== null ? `${harvest.netWeight} kg` : "—", { label: "Honigsorte", value: harvest.honeyType ?? "—" },
}, {
{ label: "Völker", value: harvest.colonies.length }, label: "Nettogewicht",
]} value: harvest.netWeight !== null ? `${harvest.netWeight} kg` : "—",
/> },
))} { label: "Völker", value: harvest.colonies.length },
</div> ]}
) : null} />
))}
</div>
)}
</QueryState>
</main> </main>
); );
} }
@@ -1,11 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Droplet } from "lucide-react"; import { ChevronDown, Droplet } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchHoneyBatches } from "@/lib/honey-batches/api"; import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { HoneyBatchStatus } from "@/lib/honey-batches/types"; import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels"; import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { exportHoneyBatchesCsv } from "@/lib/export/api"; import { exportHoneyBatchesCsv } from "@/lib/export/api";
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
@@ -14,7 +14,14 @@ 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 { 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CreateHoneyBatchDialog } from "./create-honey-batch-dialog"; import { CreateHoneyBatchDialog } from "./create-honey-batch-dialog";
const HISTORY_STATUSES: HoneyBatchStatus[] = ["SOLD", "CONSUMED"]; const HISTORY_STATUSES: HoneyBatchStatus[] = ["SOLD", "CONSUMED"];
@@ -22,7 +29,7 @@ const HISTORY_STATUSES: HoneyBatchStatus[] = ["SOLD", "CONSUMED"];
export default function HoneyBatchesPage() { export default function HoneyBatchesPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active"); const [tab, setTab] = useState<EntityLifecycleTab>("active");
const { data: honeyBatches, isLoading, isError } = useQuery({ const honeyBatchesQuery = useQuery({
queryKey: ["honey-batches"], queryKey: ["honey-batches"],
queryFn: () => fetchHoneyBatches({}), queryFn: () => fetchHoneyBatches({}),
}); });
@@ -32,12 +39,9 @@ export default function HoneyBatchesPage() {
onError: () => toast.error("CSV-Export konnte nicht heruntergeladen werden"), onError: () => toast.error("CSV-Export konnte nicht heruntergeladen werden"),
}); });
const activeBatches = (honeyBatches ?? []).filter( const honeyBatches = honeyBatchesQuery.data ?? [];
(batch) => !HISTORY_STATUSES.includes(batch.status), const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
); const historyBatches = honeyBatches.filter((batch) => HISTORY_STATUSES.includes(batch.status));
const historyBatches = (honeyBatches ?? []).filter((batch) =>
HISTORY_STATUSES.includes(batch.status),
);
const visibleBatches = tab === "active" ? activeBatches : historyBatches; const visibleBatches = tab === "active" ? activeBatches : historyBatches;
return ( return (
@@ -45,16 +49,24 @@ export default function HoneyBatchesPage() {
<PageHeader <PageHeader
icon={Droplet} icon={Droplet}
title="Honigchargen" title="Honigchargen"
description="Vom Schleudern bis zur Abfüllung" description="Hier verfolgst du jede Charge vom Schleudern bis zur Abfüllung."
actions={ actions={
<> <>
<Button <DropdownMenu>
variant="outline" <DropdownMenuTrigger render={<Button variant="outline" size="touch" />}>
disabled={exportMutation.isPending} Mehr
onClick={() => exportMutation.mutate()} <ChevronDown aria-hidden />
> </DropdownMenuTrigger>
CSV exportieren <DropdownMenuContent align="end" className="w-auto min-w-48">
</Button> <DropdownMenuItem
className="min-h-11"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate()}
>
CSV exportieren
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CreateHoneyBatchDialog /> <CreateHoneyBatchDialog />
</> </>
} }
@@ -67,49 +79,55 @@ export default function HoneyBatchesPage() {
historyCount={historyBatches.length} historyCount={historyBatches.length}
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState query={honeyBatchesQuery} what="Honigchargen">
{isError ? ( {() =>
<p role="alert" className="text-sm text-destructive"> visibleBatches.length === 0 ? (
Honigchargen konnten nicht geladen werden. tab === "active" ? (
</p> <EmptyState
) : null} icon={Droplet}
title="Noch keine Honigchargen"
{honeyBatches && visibleBatches.length === 0 ? ( description="Lege eine Charge an, sobald du geschleudert hast."
<EmptyState action={<CreateHoneyBatchDialog />}
icon={Droplet} />
title={tab === "active" ? "Noch keine Honigchargen" : "Keine Historie"} ) : (
description={ <EmptyState
tab === "active" icon={Droplet}
? "Lege eine Charge an, sobald du geschleudert hast." title="Keine Historie"
: "Verkaufte oder verbrauchte Chargen erscheinen hier." description="Verkaufte oder verbrauchte Chargen erscheinen hier."
} />
/> )
) : null} ) : (
<HoneyBatchGrid honeyBatches={visibleBatches} />
{visibleBatches.length > 0 ? ( )
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> }
{visibleBatches.map((honeyBatch) => ( </QueryState>
<EntityCard
key={honeyBatch.id}
href={`/honey-batches/${honeyBatch.id}`}
icon={Droplet}
title={<span className="font-mono">{honeyBatch.batchNumber}</span>}
subtitle={honeyBatch.honeyType ?? "—"}
badge={
<Badge variant={statusTone(honeyBatch.status)}>
{HONEY_BATCH_STATUS_LABELS[honeyBatch.status]}
</Badge>
}
fields={[
{
label: "Menge",
value: honeyBatch.amount !== null ? `${honeyBatch.amount} kg` : "—",
},
]}
/>
))}
</div>
) : null}
</main> </main>
); );
} }
function HoneyBatchGrid({ honeyBatches }: { honeyBatches: HoneyBatch[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{honeyBatches.map((honeyBatch) => (
<EntityCard
key={honeyBatch.id}
href={`/honey-batches/${honeyBatch.id}`}
icon={Droplet}
title={<span className="font-mono">{honeyBatch.batchNumber}</span>}
subtitle={honeyBatch.honeyType ?? "—"}
badge={
<Badge variant={statusTone(honeyBatch.status)}>
{HONEY_BATCH_STATUS_LABELS[honeyBatch.status]}
</Badge>
}
fields={[
{
label: "Menge",
value: honeyBatch.amount !== null ? `${honeyBatch.amount} kg` : "—",
},
]}
/>
))}
</div>
);
}
@@ -5,6 +5,7 @@ import Link from "next/link";
import { ShoppingBag } from "lucide-react"; import { ShoppingBag } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchProducts } from "@/lib/products/api"; import { fetchProducts } from "@/lib/products/api";
import { Product } from "@/lib/products/types";
import { PRODUCT_STATUS_LABELS } from "@/lib/products/labels"; import { PRODUCT_STATUS_LABELS } from "@/lib/products/labels";
import { EditProductDialog } from "./edit-product-dialog"; import { EditProductDialog } from "./edit-product-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -12,18 +13,20 @@ 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 { 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";
export default function ProductsPage() { export default function ProductsPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active"); const [tab, setTab] = useState<EntityLifecycleTab>("active");
const { data: products, isLoading, isError } = useQuery({ const productsQuery = useQuery({
queryKey: ["products"], queryKey: ["products"],
queryFn: () => fetchProducts(), queryFn: () => fetchProducts(),
}); });
const activeProducts = (products ?? []).filter((product) => product.status === "ACTIVE"); const products = productsQuery.data ?? [];
const historyProducts = (products ?? []).filter((product) => product.status !== "ACTIVE"); const activeProducts = products.filter((product) => product.status === "ACTIVE");
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
const visibleProducts = tab === "active" ? activeProducts : historyProducts; const visibleProducts = tab === "active" ? activeProducts : historyProducts;
return ( return (
@@ -31,9 +34,9 @@ export default function ProductsPage() {
<PageHeader <PageHeader
icon={ShoppingBag} icon={ShoppingBag}
title="Produkte" title="Produkte"
description="Verkaufsfertige Produkte" description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
actions={ actions={
<Button nativeButton={false} render={<Link href="/products/new" />}> <Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
+ Neues Produkt + Neues Produkt
</Button> </Button>
} }
@@ -46,46 +49,56 @@ export default function ProductsPage() {
historyCount={historyProducts.length} historyCount={historyProducts.length}
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState query={productsQuery} what="Produkte">
{isError ? ( {() =>
<p role="alert" className="text-sm text-destructive"> visibleProducts.length === 0 ? (
Produkte konnten nicht geladen werden. tab === "active" ? (
</p> <EmptyState
) : null} icon={ShoppingBag}
title="Noch keine Produkte"
{products && visibleProducts.length === 0 ? ( description="Lege dein erstes verkaufsfertiges Produkt an."
<EmptyState action={
icon={ShoppingBag} <Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
title={tab === "active" ? "Noch keine Produkte" : "Keine Historie"} Erstes Produkt anlegen
description={ </Button>
tab === "active" }
? "Lege dein erstes verkaufsfertiges Produkt an." />
: "Eingestellte Produkte erscheinen hier." ) : (
} <EmptyState
/> icon={ShoppingBag}
) : null} title="Keine Historie"
description="Eingestellte Produkte erscheinen hier."
{visibleProducts.length > 0 ? ( />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> )
{visibleProducts.map((product) => ( ) : (
<EntityCard <ProductGrid products={visibleProducts} />
key={product.id} )
icon={ShoppingBag} }
title={product.name} </QueryState>
badge={
<Badge variant={product.status === "ACTIVE" ? "default" : "secondary"}>
{PRODUCT_STATUS_LABELS[product.status]}
</Badge>
}
fields={[
{ label: "SKU", value: product.sku ?? "—" },
{ label: "Einheit", value: product.salesUnit ?? "—" },
]}
actions={<EditProductDialog product={product} />}
/>
))}
</div>
) : null}
</main> </main>
); );
} }
function ProductGrid({ products }: { products: Product[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<EntityCard
key={product.id}
icon={ShoppingBag}
title={product.name}
badge={
<Badge variant={product.status === "ACTIVE" ? "default" : "secondary"}>
{PRODUCT_STATUS_LABELS[product.status]}
</Badge>
}
fields={[
{ label: "SKU", value: product.sku ?? "—" },
{ label: "Einheit", value: product.salesUnit ?? "—" },
]}
actions={<EditProductDialog product={product} />}
/>
))}
</div>
);
}
+93 -48
View File
@@ -5,13 +5,14 @@ import Link from "next/link";
import { Crown } from "lucide-react"; import { Crown } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchQueens } from "@/lib/queens/api"; import { fetchQueens } from "@/lib/queens/api";
import { QueenStatus } from "@/lib/queens/types"; import { Queen, QueenStatus } from "@/lib/queens/types";
import { QUEEN_STATUSES, QUEEN_STATUS_LABELS } from "@/lib/queens/labels"; import { QUEEN_STATUSES, QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
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 { import {
Select, Select,
@@ -25,13 +26,14 @@ export default function QueensPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active"); const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [status, setStatus] = useState<QueenStatus | "">(""); const [status, setStatus] = useState<QueenStatus | "">("");
const { data: queens, isLoading, isError } = useQuery({ const queensQuery = useQuery({
queryKey: ["queens"], queryKey: ["queens"],
queryFn: () => fetchQueens({}), queryFn: () => fetchQueens({}),
}); });
const activeQueens = (queens ?? []).filter((queen) => queen.deactivatedAt === null); const queens = queensQuery.data ?? [];
const historyQueens = (queens ?? []).filter((queen) => queen.deactivatedAt !== null); const activeQueens = queens.filter((queen) => queen.deactivatedAt === null);
const historyQueens = queens.filter((queen) => queen.deactivatedAt !== null);
const bucketQueens = tab === "active" ? activeQueens : historyQueens; const bucketQueens = tab === "active" ? activeQueens : historyQueens;
const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens; const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
@@ -40,10 +42,10 @@ export default function QueensPage() {
<PageHeader <PageHeader
icon={Crown} icon={Crown}
title="Königinnen" title="Königinnen"
description="Zuchtlinien und Bewertungen" description="Hier verwaltest du deine Königinnen mit Zuchtlinie und Bewertung."
gradient="var(--gradient-sage)" gradient="var(--gradient-sage)"
actions={ actions={
<Button nativeButton={false} render={<Link href="/queens/new" />}> <Button size="touch" nativeButton={false} render={<Link href="/queens/new" />}>
+ Neue Königin + Neue Königin
</Button> </Button>
} }
@@ -63,7 +65,7 @@ export default function QueensPage() {
value={status || "ALL"} value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))} onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40" aria-label="Nach Status filtern">
<SelectValue placeholder="Status"> <SelectValue placeholder="Status">
{status ? QUEEN_STATUS_LABELS[status] : "Alle Status"} {status ? QUEEN_STATUS_LABELS[status] : "Alle Status"}
</SelectValue> </SelectValue>
@@ -79,48 +81,91 @@ export default function QueensPage() {
</Select> </Select>
</div> </div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState query={queensQuery} what="Königinnen">
{isError ? ( {() =>
<p role="alert" className="text-sm text-destructive"> visibleQueens.length === 0 ? (
Königinnen konnten nicht geladen werden. <QueensEmpty
</p> tab={tab}
) : null} isFiltered={status !== "" && bucketQueens.length > 0}
onResetFilter={() => setStatus("")}
{queens && visibleQueens.length === 0 ? (
<EmptyState
icon={Crown}
title={tab === "active" ? "Noch keine Königinnen" : "Keine Historie"}
description={
tab === "active"
? "Lege deine erste Königin an, um Zuchtlinien zu verfolgen."
: "Deaktivierte Königinnen erscheinen hier."
}
/>
) : null}
{visibleQueens.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleQueens.map((queen) => (
<EntityCard
key={queen.id}
href={`/queens/${queen.id}`}
icon={Crown}
gradient="var(--gradient-sage)"
title={queen.code}
subtitle={queen.breed ?? "—"}
badge={
<Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}>
{QUEEN_STATUS_LABELS[queen.status]}
</Badge>
}
fields={[
{ label: "Schlupfjahr", value: queen.hatchYear ?? "—" },
{ label: "Zeichenfarbe", value: queen.markingColor ?? "—" },
]}
/> />
))} ) : (
</div> <QueenGrid queens={visibleQueens} />
) : null} )
}
</QueryState>
</main> </main>
); );
} }
function QueensEmpty({
tab,
isFiltered,
onResetFilter,
}: {
tab: EntityLifecycleTab;
isFiltered: boolean;
onResetFilter: () => void;
}) {
if (isFiltered) {
return (
<EmptyState
icon={Crown}
title="Keine Königin mit diesem Status"
description="Der Filter blendet alle Königinnen aus."
action={
<Button size="touch" variant="outline" onClick={onResetFilter}>
Filter zurücksetzen
</Button>
}
/>
);
}
if (tab === "history") {
return (
<EmptyState
icon={Crown}
title="Keine Historie"
description="Deaktivierte Königinnen erscheinen hier."
/>
);
}
return (
<EmptyState
icon={Crown}
title="Noch keine Königinnen"
description="Lege deine erste Königin an, um Zuchtlinien zu verfolgen."
action={
<Button size="touch" nativeButton={false} render={<Link href="/queens/new" />}>
Erste Königin anlegen
</Button>
}
/>
);
}
function QueenGrid({ queens }: { queens: Queen[] }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{queens.map((queen) => (
<EntityCard
key={queen.id}
href={`/queens/${queen.id}`}
icon={Crown}
gradient="var(--gradient-sage)"
title={queen.code}
subtitle={queen.breed ?? "—"}
badge={
<Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}>
{QUEEN_STATUS_LABELS[queen.status]}
</Badge>
}
fields={[
{ label: "Schlupfjahr", value: queen.hatchYear ?? "—" },
{ label: "Zeichenfarbe", value: queen.markingColor ?? "—" },
]}
/>
))}
</div>
);
}