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
+407
-212
No files matched your search
@@ -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,33 +73,79 @@ 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("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ApiaryGrid apiaries={visibleApiaries} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{apiaries && visibleApiaries.length === 0 ? (
|
function ApiariesEmpty({
|
||||||
|
tab,
|
||||||
|
isFiltered,
|
||||||
|
onResetFilter,
|
||||||
|
}: {
|
||||||
|
tab: EntityLifecycleTab;
|
||||||
|
isFiltered: boolean;
|
||||||
|
onResetFilter: () => void;
|
||||||
|
}) {
|
||||||
|
if (isFiltered) {
|
||||||
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={MapPin}
|
icon={MapPin}
|
||||||
title={tab === "active" ? "Noch keine Bienenstände" : "Keine Historie"}
|
title="Kein Bienenstand an diesem Standort"
|
||||||
description={
|
description="Der Standort-Filter blendet alle Bienenstände aus."
|
||||||
tab === "active"
|
action={
|
||||||
? "Lege deinen ersten Bienenstand an, um Völker zuzuordnen."
|
<Button size="touch" variant="outline" onClick={onResetFilter}>
|
||||||
: "Deaktivierte Bienenstände erscheinen hier."
|
Filter zurücksetzen
|
||||||
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
);
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{visibleApiaries.length > 0 ? (
|
function ApiaryGrid({ apiaries }: { apiaries: Apiary[] }) {
|
||||||
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{visibleApiaries.map((apiary) => (
|
{apiaries.map((apiary) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
key={apiary.id}
|
key={apiary.id}
|
||||||
href={`/apiaries/${apiary.id}`}
|
href={`/apiaries/${apiary.id}`}
|
||||||
@@ -99,7 +161,5 @@ export default function ApiariesPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,30 +25,31 @@ 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>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{bottlingBatches && bottlingBatches.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Package}
|
icon={Package}
|
||||||
title="Noch keine Abfüllungen"
|
title="Noch keine Abfüllungen"
|
||||||
description="Lege eine Abfüllung an, sobald du Honig in Gläser gefüllt hast."
|
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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
}
|
||||||
|
>
|
||||||
{bottlingBatches && bottlingBatches.length > 0 ? (
|
{(bottlingBatches) => (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{bottlingBatches.map((bottlingBatch) => (
|
{bottlingBatches.map((bottlingBatch) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
@@ -66,7 +68,8 @@ export default function BottlingBatchesPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
</QueryState>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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" />}>
|
||||||
|
Mehr
|
||||||
|
<ChevronDown aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-auto min-w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="min-h-11"
|
||||||
disabled={exportMutation.isPending}
|
disabled={exportMutation.isPending}
|
||||||
onClick={() => exportMutation.mutate()}
|
onClick={() => exportMutation.mutate()}
|
||||||
>
|
>
|
||||||
CSV exportieren
|
CSV exportieren
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
<Button nativeButton={false} render={<Link href="/colonies/new" />}>
|
</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,28 +115,73 @@ 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("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ColonyGrid colonies={visibleColonies} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{colonies && visibleColonies.length === 0 ? (
|
function ColoniesEmpty({
|
||||||
|
tab,
|
||||||
|
isFiltered,
|
||||||
|
onResetFilter,
|
||||||
|
}: {
|
||||||
|
tab: EntityLifecycleTab;
|
||||||
|
isFiltered: boolean;
|
||||||
|
onResetFilter: () => void;
|
||||||
|
}) {
|
||||||
|
if (isFiltered) {
|
||||||
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Layers}
|
icon={Layers}
|
||||||
title={tab === "active" ? "Noch keine Völker" : "Keine Historie"}
|
title="Kein Volk mit diesem Status"
|
||||||
description={
|
description="Der Filter blendet alle Völker aus."
|
||||||
tab === "active"
|
action={
|
||||||
? "Lege dein erstes Volk an, um den Überblick zu behalten."
|
<Button size="touch" variant="outline" onClick={onResetFilter}>
|
||||||
: "Aufgelöste, verkaufte oder verstorbene Völker erscheinen hier."
|
Filter zurücksetzen
|
||||||
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
);
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{visibleColonies.length > 0 ? (
|
function ColonyGrid({ colonies }: { colonies: Colony[] }) {
|
||||||
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{visibleColonies.map((colony) => (
|
{colonies.map((colony) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
key={colony.id}
|
key={colony.id}
|
||||||
href={`/colonies/${colony.id}`}
|
href={`/colonies/${colony.id}`}
|
||||||
@@ -128,9 +189,7 @@ export default function ColoniesPage() {
|
|||||||
title={<span className="font-mono">{colony.code}</span>}
|
title={<span className="font-mono">{colony.code}</span>}
|
||||||
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
|
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
|
||||||
badge={
|
badge={
|
||||||
<Badge variant={statusTone(colony.status)}>
|
<Badge variant={statusTone(colony.status)}>{COLONY_STATUS_LABELS[colony.status]}</Badge>
|
||||||
{COLONY_STATUS_LABELS[colony.status]}
|
|
||||||
</Badge>
|
|
||||||
}
|
}
|
||||||
fields={[
|
fields={[
|
||||||
{ label: "Stärke", value: colony.strength ?? "—" },
|
{ label: "Stärke", value: colony.strength ?? "—" },
|
||||||
@@ -142,7 +201,5 @@ export default function ColoniesPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,31 +24,32 @@ 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>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{equipmentList && equipmentList.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
title="Noch kein Inventar"
|
title="Noch kein Inventar"
|
||||||
description="Erfasse deinen ersten Gegenstand, um den Zustand im Blick zu behalten."
|
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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
}
|
||||||
|
>
|
||||||
{equipmentList && equipmentList.length > 0 ? (
|
{(equipmentList) => (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{equipmentList.map((item) => (
|
{equipmentList.map((item) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
@@ -66,7 +68,8 @@ export default function EquipmentPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
</QueryState>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,49 +8,45 @@ 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>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{extractions && sortedExtractions.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={FlaskConical}
|
icon={FlaskConical}
|
||||||
title="Noch keine Schleuderungen"
|
title="Noch keine Schleuderungen"
|
||||||
description="Erfasse deine erste Schleuderung aus einer oder mehreren Ernten."
|
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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
}
|
||||||
|
>
|
||||||
{sortedExtractions.length > 0 ? (
|
{(extractions) => (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{sortedExtractions.map((extraction) => (
|
{[...extractions]
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
|
.map((extraction) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
key={extraction.id}
|
key={extraction.id}
|
||||||
href={`/extractions/${extraction.id}`}
|
href={`/extractions/${extraction.id}`}
|
||||||
@@ -71,7 +67,8 @@ export default function ExtractionsPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
</QueryState>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,51 +9,49 @@ 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>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{harvests && sortedHarvests.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Scissors}
|
icon={Scissors}
|
||||||
title="Noch keine Ernten"
|
title="Noch keine Ernten"
|
||||||
description="Erfasse deine erste Ernte für einen Bienenstand."
|
description="Erfasse deine erste Ernte für einen Bienenstand."
|
||||||
|
action={
|
||||||
|
<Button size="touch" nativeButton={false} render={<Link href="/harvests/new" />}>
|
||||||
|
Erste Ernte erfassen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
}
|
||||||
|
>
|
||||||
{sortedHarvests.length > 0 ? (
|
{(harvests) => (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{sortedHarvests.map((harvest) => (
|
{[...harvests]
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
|
.map((harvest) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
key={harvest.id}
|
key={harvest.id}
|
||||||
href={`/harvests/${harvest.id}`}
|
href={`/harvests/${harvest.id}`}
|
||||||
@@ -71,7 +69,8 @@ export default function HarvestsPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
</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" />}>
|
||||||
|
Mehr
|
||||||
|
<ChevronDown aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-auto min-w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="min-h-11"
|
||||||
disabled={exportMutation.isPending}
|
disabled={exportMutation.isPending}
|
||||||
onClick={() => exportMutation.mutate()}
|
onClick={() => exportMutation.mutate()}
|
||||||
>
|
>
|
||||||
CSV exportieren
|
CSV exportieren
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<CreateHoneyBatchDialog />
|
<CreateHoneyBatchDialog />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -67,28 +79,36 @@ 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>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{honeyBatches && visibleBatches.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Droplet}
|
icon={Droplet}
|
||||||
title={tab === "active" ? "Noch keine Honigchargen" : "Keine Historie"}
|
title="Noch keine Honigchargen"
|
||||||
description={
|
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||||
tab === "active"
|
action={<CreateHoneyBatchDialog />}
|
||||||
? "Lege eine Charge an, sobald du geschleudert hast."
|
|
||||||
: "Verkaufte oder verbrauchte Chargen erscheinen hier."
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Droplet}
|
||||||
|
title="Keine Historie"
|
||||||
|
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<HoneyBatchGrid honeyBatches={visibleBatches} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{visibleBatches.length > 0 ? (
|
function HoneyBatchGrid({ honeyBatches }: { honeyBatches: HoneyBatch[] }) {
|
||||||
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{visibleBatches.map((honeyBatch) => (
|
{honeyBatches.map((honeyBatch) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
key={honeyBatch.id}
|
key={honeyBatch.id}
|
||||||
href={`/honey-batches/${honeyBatch.id}`}
|
href={`/honey-batches/${honeyBatch.id}`}
|
||||||
@@ -109,7 +129,5 @@ export default function HoneyBatchesPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,28 +49,40 @@ 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>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{products && visibleProducts.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ShoppingBag}
|
icon={ShoppingBag}
|
||||||
title={tab === "active" ? "Noch keine Produkte" : "Keine Historie"}
|
title="Noch keine Produkte"
|
||||||
description={
|
description="Lege dein erstes verkaufsfertiges Produkt an."
|
||||||
tab === "active"
|
action={
|
||||||
? "Lege dein erstes verkaufsfertiges Produkt an."
|
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||||
: "Eingestellte Produkte erscheinen hier."
|
Erstes Produkt anlegen
|
||||||
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShoppingBag}
|
||||||
|
title="Keine Historie"
|
||||||
|
description="Eingestellte Produkte erscheinen hier."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ProductGrid products={visibleProducts} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{visibleProducts.length > 0 ? (
|
function ProductGrid({ products }: { products: Product[] }) {
|
||||||
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{visibleProducts.map((product) => (
|
{products.map((product) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
key={product.id}
|
key={product.id}
|
||||||
icon={ShoppingBag}
|
icon={ShoppingBag}
|
||||||
@@ -85,7 +100,5 @@ export default function ProductsPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,28 +81,73 @@ 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("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueenGrid queens={visibleQueens} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{queens && visibleQueens.length === 0 ? (
|
function QueensEmpty({
|
||||||
|
tab,
|
||||||
|
isFiltered,
|
||||||
|
onResetFilter,
|
||||||
|
}: {
|
||||||
|
tab: EntityLifecycleTab;
|
||||||
|
isFiltered: boolean;
|
||||||
|
onResetFilter: () => void;
|
||||||
|
}) {
|
||||||
|
if (isFiltered) {
|
||||||
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Crown}
|
icon={Crown}
|
||||||
title={tab === "active" ? "Noch keine Königinnen" : "Keine Historie"}
|
title="Keine Königin mit diesem Status"
|
||||||
description={
|
description="Der Filter blendet alle Königinnen aus."
|
||||||
tab === "active"
|
action={
|
||||||
? "Lege deine erste Königin an, um Zuchtlinien zu verfolgen."
|
<Button size="touch" variant="outline" onClick={onResetFilter}>
|
||||||
: "Deaktivierte Königinnen erscheinen hier."
|
Filter zurücksetzen
|
||||||
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
);
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{visibleQueens.length > 0 ? (
|
function QueenGrid({ queens }: { queens: Queen[] }) {
|
||||||
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{visibleQueens.map((queen) => (
|
{queens.map((queen) => (
|
||||||
<EntityCard
|
<EntityCard
|
||||||
key={queen.id}
|
key={queen.id}
|
||||||
href={`/queens/${queen.id}`}
|
href={`/queens/${queen.id}`}
|
||||||
@@ -120,7 +167,5 @@ export default function QueensPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user