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