feat: card-grid boxes with Aktiv/Historie split on main list pages

Replace the list-row layout with a responsive box grid (EntityCard):
icon avatar, title, badge, and 1-2 key fields visible at a glance per
box, matching what the user asked for ("Kaestchen" over rows).

Add an Aktiv/Historie tab split (ActiveHistoryTabs) so deactivated or
terminal-status entries move out of the default view instead of mixing
in with active ones:
- Apiaries, Colonies, Queens: split on deactivatedAt (the field the app
  already uses everywhere for "Historie statt Loeschen").
- Task templates: split on the existing active boolean.
- Tasks: OPEN/IN_PROGRESS vs DONE/CANCELLED.
- Honey batches: everything vs SOLD/CONSUMED.
- Products: ACTIVE vs DISCONTINUED.
- Storage containers: everything vs OUT_OF_SERVICE.
Equipment and stock items have no lifecycle field in the schema, so
they keep the plain grid without a Historie tab.

Verified live: deactivating a Bienenstand moves it out of Aktiv into
Historie immediately, both counts update, reactivating moves it back.
This commit is contained in:
Nick Szaunig committed 2026-08-26 00:25:23 +02:00
1 parent c586bff18f
commit d31aa79f50
12 files changed
+496 -320

No files matched your search

@@ -5,30 +5,30 @@ import Link from "next/link";
import { MapPin } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchApiaries } from "@/lib/apiaries/api";
import { ApiaryStatus } 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 { ListItemCard } from "@/components/list-item-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
export default function ApiariesPage() {
const [status, setStatus] = useState<ApiaryStatus | "">("");
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [location, setLocation] = useState("");
const { data: apiaries, isLoading, isError } = useQuery({
queryKey: ["apiaries", status, location],
queryFn: () => fetchApiaries({ status: status || undefined, location: location || undefined }),
queryKey: ["apiaries"],
queryFn: () => fetchApiaries({}),
});
const byLocation = (apiaries ?? []).filter((apiary) =>
location ? (apiary.location ?? "").toLowerCase().includes(location.toLowerCase()) : true,
);
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
const historyApiaries = byLocation.filter((apiary) => apiary.status !== "ACTIVE");
const visibleApiaries = tab === "active" ? activeApiaries : historyApiaries;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
@@ -48,19 +48,12 @@ export default function ApiariesPage() {
/>
<div className="flex flex-wrap items-center gap-3">
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ApiaryStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
<SelectItem value="ACTIVE">Aktiv</SelectItem>
<SelectItem value="INACTIVE">Inaktiv</SelectItem>
</SelectContent>
</Select>
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={activeApiaries.length}
historyCount={historyApiaries.length}
/>
<Input
className="w-48"
placeholder="Standort"
@@ -76,28 +69,33 @@ export default function ApiariesPage() {
</p>
) : null}
{apiaries && apiaries.length === 0 ? (
{apiaries && visibleApiaries.length === 0 ? (
<EmptyState
icon={MapPin}
title="Noch keine Bienenstände"
description="Lege deinen ersten Bienenstand an, um Völker zuzuordnen."
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}
{apiaries && apiaries.length > 0 ? (
<div className="flex flex-col gap-2">
{apiaries.map((apiary) => (
<ListItemCard
{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 ?? "—"}
trailing={
badge={
<Badge variant={apiary.status === "ACTIVE" ? "default" : "secondary"}>
{apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
</Badge>
}
fields={[{ label: "Adresse", value: apiary.address ?? "—" }]}
/>
))}
</div>
@@ -12,7 +12,8 @@ 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 { ListItemCard } from "@/components/list-item-card";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import {
Select,
SelectContent,
@@ -21,27 +22,26 @@ import {
SelectValue,
} from "@/components/ui/select";
const STATUS_OPTIONS: ColonyStatus[] = [
"ACTIVE",
"WEAK",
"CONSPICUOUS",
"QUEENLESS",
"DISSOLVED",
"SOLD",
"DECEASED",
"MERGED",
];
const ACTIVE_STATUS_OPTIONS: ColonyStatus[] = ["ACTIVE", "WEAK", "CONSPICUOUS", "QUEENLESS"];
const HISTORY_STATUS_OPTIONS: ColonyStatus[] = ["DISSOLVED", "SOLD", "DECEASED", "MERGED"];
export default function ColoniesPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [status, setStatus] = useState<ColonyStatus | "">("");
const { data: colonies, isLoading, isError } = useQuery({
queryKey: ["colonies", status],
queryFn: () => fetchColonies({ status: status || undefined }),
queryKey: ["colonies"],
queryFn: () => fetchColonies({}),
});
const exportMutation = useMutation({ mutationFn: exportColoniesCsv });
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;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
@@ -64,22 +64,33 @@ export default function ColoniesPage() {
}
/>
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ColonyStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-3">
<ActiveHistoryTabs
value={tab}
onChange={(next) => {
setTab(next);
setStatus("");
}}
activeCount={activeColonies.length}
historyCount={historyColonies.length}
/>
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as ColonyStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{statusOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
@@ -88,24 +99,32 @@ export default function ColoniesPage() {
</p>
) : null}
{colonies && colonies.length === 0 ? (
{colonies && visibleColonies.length === 0 ? (
<EmptyState
icon={Layers}
title="Noch keine Völker"
description="Lege dein erstes Volk an, um den Überblick zu behalten."
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}
{colonies && colonies.length > 0 ? (
<div className="flex flex-col gap-2">
{colonies.map((colony) => (
<ListItemCard
{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.colonyType}
trailing={<Badge variant={statusTone(colony.status)}>{colony.status}</Badge>}
badge={<Badge variant={statusTone(colony.status)}>{colony.status}</Badge>}
fields={[
{ label: "Stärke", value: colony.strength ?? "—" },
{ label: "Gesundheit", value: colony.healthStatus ?? "—" },
]}
/>
))}
</div>
@@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ListItemCard } from "@/components/list-item-card";
import { EntityCard } from "@/components/entity-card";
export default function EquipmentPage() {
const { data: equipmentList, isLoading, isError } = useQuery({
@@ -47,16 +47,17 @@ export default function EquipmentPage() {
) : null}
{equipmentList && equipmentList.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{equipmentList.map((item) => (
<ListItemCard
<EntityCard
key={item.id}
href={`/equipment/${item.id}`}
icon={Wrench}
gradient="var(--gradient-sage)"
title={item.name}
subtitle={`${item.inventoryNumber} · ${item.location ?? "—"}`}
trailing={<Badge variant={statusTone(item.condition)}>{item.condition}</Badge>}
subtitle={item.inventoryNumber}
badge={<Badge variant={statusTone(item.condition)}>{item.condition}</Badge>}
fields={[{ label: "Standort", value: item.location ?? "—" }]}
/>
))}
</div>
@@ -11,37 +11,30 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ListItemCard } from "@/components/list-item-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { CreateHoneyBatchDialog } from "./create-honey-batch-dialog";
const STATUS_OPTIONS: HoneyBatchStatus[] = [
"RAW",
"EXTRACTED",
"CLARIFIED",
"CREAMED",
"STORED",
"BOTTLED",
"SOLD",
"CONSUMED",
];
const HISTORY_STATUSES: HoneyBatchStatus[] = ["SOLD", "CONSUMED"];
export default function HoneyBatchesPage() {
const [status, setStatus] = useState<HoneyBatchStatus | "">("");
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const { data: honeyBatches, isLoading, isError } = useQuery({
queryKey: ["honey-batches", status],
queryFn: () => fetchHoneyBatches({ status: status || undefined }),
queryKey: ["honey-batches"],
queryFn: () => fetchHoneyBatches({}),
});
const exportMutation = useMutation({ mutationFn: exportHoneyBatchesCsv });
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 (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
@@ -62,24 +55,12 @@ export default function HoneyBatchesPage() {
}
/>
<Select
value={status || "ALL"}
onValueChange={(value) =>
setStatus(value === "ALL" ? "" : ((value ?? "") as HoneyBatchStatus))
}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={activeBatches.length}
historyCount={historyBatches.length}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
@@ -88,28 +69,34 @@ export default function HoneyBatchesPage() {
</p>
) : null}
{honeyBatches && honeyBatches.length === 0 ? (
{honeyBatches && visibleBatches.length === 0 ? (
<EmptyState
icon={Droplet}
title="Noch keine Honigchargen"
description="Lege eine Charge an, sobald du geschleudert hast."
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}
{honeyBatches && honeyBatches.length > 0 ? (
<div className="flex flex-col gap-2">
{honeyBatches.map((honeyBatch) => (
<ListItemCard
{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 ?? "—"} · ${
honeyBatch.amount !== null ? `${honeyBatch.amount} kg` : "—"
}`}
trailing={
<Badge variant={statusTone(honeyBatch.status)}>{honeyBatch.status}</Badge>
}
subtitle={honeyBatch.honeyType ?? "—"}
badge={<Badge variant={statusTone(honeyBatch.status)}>{honeyBatch.status}</Badge>}
fields={[
{
label: "Menge",
value: honeyBatch.amount !== null ? `${honeyBatch.amount} kg` : "—",
},
]}
/>
))}
</div>
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ShoppingBag } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
@@ -8,14 +9,21 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ListItemCard } from "@/components/list-item-card";
import { EntityCard } from "@/components/entity-card";
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({
queryKey: ["products"],
queryFn: () => fetchProducts(),
});
const activeProducts = (products ?? []).filter((product) => product.status === "ACTIVE");
const historyProducts = (products ?? []).filter((product) => product.status !== "ACTIVE");
const visibleProducts = tab === "active" ? activeProducts : historyProducts;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
@@ -29,6 +37,13 @@ export default function ProductsPage() {
}
/>
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={activeProducts.length}
historyCount={historyProducts.length}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
@@ -36,27 +51,34 @@ export default function ProductsPage() {
</p>
) : null}
{products && products.length === 0 ? (
{products && visibleProducts.length === 0 ? (
<EmptyState
icon={ShoppingBag}
title="Noch keine Produkte"
description="Lege dein erstes verkaufsfertiges Produkt an."
title={tab === "active" ? "Noch keine Produkte" : "Keine Historie"}
description={
tab === "active"
? "Lege dein erstes verkaufsfertiges Produkt an."
: "Eingestellte Produkte erscheinen hier."
}
/>
) : null}
{products && products.length > 0 ? (
<div className="flex flex-col gap-2">
{products.map((product) => (
<ListItemCard
{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}
subtitle={`${product.sku ?? "—"} · ${product.salesUnit ?? "—"}`}
trailing={
badge={
<Badge variant={product.status === "ACTIVE" ? "default" : "secondary"}>
{product.status}
</Badge>
}
fields={[
{ label: "SKU", value: product.sku ?? "—" },
{ label: "Einheit", value: product.salesUnit ?? "—" },
]}
/>
))}
</div>
+50 -27
View File
@@ -10,7 +10,8 @@ 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 { ListItemCard } from "@/components/list-item-card";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import {
Select,
SelectContent,
@@ -31,13 +32,19 @@ const STATUS_OPTIONS: QueenStatus[] = [
];
export default function QueensPage() {
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [status, setStatus] = useState<QueenStatus | "">("");
const { data: queens, isLoading, isError } = useQuery({
queryKey: ["queens", status],
queryFn: () => fetchQueens({ status: status || undefined }),
queryKey: ["queens"],
queryFn: () => fetchQueens({}),
});
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;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
@@ -52,22 +59,33 @@ export default function QueensPage() {
}
/>
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-3">
<ActiveHistoryTabs
value={tab}
onChange={(next) => {
setTab(next);
setStatus("");
}}
activeCount={activeQueens.length}
historyCount={historyQueens.length}
/>
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as QueenStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
@@ -76,29 +94,34 @@ export default function QueensPage() {
</p>
) : null}
{queens && queens.length === 0 ? (
{queens && visibleQueens.length === 0 ? (
<EmptyState
icon={Crown}
title="Noch keine Königinnen"
description="Lege deine erste Königin an, um Zuchtlinien zu verfolgen."
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}
{queens && queens.length > 0 ? (
<div className="flex flex-col gap-2">
{queens.map((queen) => (
<ListItemCard
{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 ?? "—"}
trailing={
badge={
<Badge variant={queen.status === "ACTIVE" ? "default" : "secondary"}>
{queen.status}
</Badge>
}
fields={[{ label: "Schlupfjahr", value: queen.hatchYear ?? "—" }]}
/>
))}
</div>
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Repeat } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -8,7 +9,9 @@ import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/t
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import { ListItemCard } from "@/components/list-item-card";
import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { CreateTaskTemplateDialog } from "./create-task-template-dialog";
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
@@ -33,6 +36,7 @@ function formatDate(value: string): string {
export default function TaskTemplatesPage() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const { data: templates, isLoading, isError } = useQuery({
queryKey: ["task-templates"],
queryFn: fetchTaskTemplates,
@@ -47,6 +51,10 @@ export default function TaskTemplatesPage() {
onError: () => toast.error("Vorlage konnte nicht deaktiviert werden"),
});
const activeTemplates = (templates ?? []).filter((template) => template.active);
const historyTemplates = (templates ?? []).filter((template) => !template.active);
const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
@@ -57,6 +65,13 @@ export default function TaskTemplatesPage() {
actions={<CreateTaskTemplateDialog />}
/>
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={activeTemplates.length}
historyCount={historyTemplates.length}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
@@ -64,34 +79,45 @@ export default function TaskTemplatesPage() {
</p>
) : null}
{templates && templates.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Vorlagen angelegt.</p>
{templates && visibleTemplates.length === 0 ? (
<EmptyState
icon={Repeat}
title={tab === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
description={
tab === "active"
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
: "Deaktivierte Vorlagen erscheinen hier."
}
/>
) : null}
{templates && templates.length > 0 ? (
<div className="flex flex-col gap-2">
{templates.map((template) => (
<ListItemCard
{visibleTemplates.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleTemplates.map((template) => (
<EntityCard
key={template.id}
icon={Repeat}
gradient="var(--gradient-sky)"
title={template.title}
subtitle={`${describeRecurrence(template)} · Nächste Fälligkeit: ${formatDate(template.nextDueDate)}`}
trailing={
<>
<Badge variant={template.active ? "default" : "outline"}>
{template.active ? "Aktiv" : "Inaktiv"}
</Badge>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!template.active || deactivateMutation.isPending}
onClick={() => deactivateMutation.mutate(template.id)}
>
Deaktivieren
</Button>
</>
badge={
<Badge variant={template.active ? "default" : "outline"}>
{template.active ? "Aktiv" : "Inaktiv"}
</Badge>
}
fields={[
{ label: "Wiederholung", value: describeRecurrence(template) },
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
]}
actions={
<Button
type="button"
variant="ghost"
size="sm"
disabled={!template.active || deactivateMutation.isPending}
onClick={() => deactivateMutation.mutate(template.id)}
>
Deaktivieren
</Button>
}
/>
))}
+49 -29
View File
@@ -1,18 +1,21 @@
"use client";
import { useState } from "react";
import { Package, Warehouse } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchStockItems, fetchStorageContainers } from "@/lib/storage/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ListItemCard } from "@/components/list-item-card";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import { Badge } from "@/components/ui/badge";
import { CreateStorageContainerDialog } from "./create-storage-container-dialog";
import { CreateStockItemDialog } from "./create-stock-item-dialog";
import { RecordMovementDialog } from "./record-movement-dialog";
export default function StoragePage() {
const [containerTab, setContainerTab] = useState<EntityLifecycleTab>("active");
const { data: containers, isLoading: containersLoading } = useQuery({
queryKey: ["storage-containers"],
queryFn: fetchStorageContainers,
@@ -22,6 +25,14 @@ export default function StoragePage() {
queryFn: fetchStockItems,
});
const activeContainers = (containers ?? []).filter(
(container) => container.status !== "OUT_OF_SERVICE",
);
const historyContainers = (containers ?? []).filter(
(container) => container.status === "OUT_OF_SERVICE",
);
const visibleContainers = containerTab === "active" ? activeContainers : historyContainers;
return (
<main className="flex flex-col gap-8 p-4 sm:p-6">
<section className="flex flex-col gap-4">
@@ -32,32 +43,45 @@ export default function StoragePage() {
gradient="var(--gradient-sky)"
actions={<CreateStorageContainerDialog />}
/>
<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>
<ActiveHistoryTabs
value={containerTab}
onChange={setContainerTab}
activeCount={activeContainers.length}
historyCount={historyContainers.length}
/>
</div>
{containersLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{containers && containers.length === 0 ? (
{containers && visibleContainers.length === 0 ? (
<EmptyState
icon={Warehouse}
title="Noch keine Lagerbehälter"
description="Lege deinen ersten Behälter an."
title={containerTab === "active" ? "Noch keine Lagerbehälter" : "Keine Historie"}
description={
containerTab === "active"
? "Lege deinen ersten Behälter an."
: "Außer Betrieb genommene Behälter erscheinen hier."
}
/>
) : null}
{containers && containers.length > 0 ? (
<div className="flex flex-col gap-2">
{containers.map((container) => (
<ListItemCard
{visibleContainers.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleContainers.map((container) => (
<EntityCard
key={container.id}
icon={Warehouse}
gradient="var(--gradient-sky)"
title={<span className="font-mono">{container.code}</span>}
subtitle={`${container.containerType ?? "—"} · ${container.location ?? "—"}`}
trailing={
<>
<Badge variant={container.status === "FILLED" ? "default" : "secondary"}>
{container.status}
</Badge>
<QrCodeDialog entityType="storage-containers" id={container.id} label="QR" />
</>
subtitle={container.containerType ?? "—"}
badge={
<Badge variant={container.status === "FILLED" ? "default" : "secondary"}>
{container.status}
</Badge>
}
fields={[{ label: "Standort", value: container.location ?? "—" }]}
actions={
<QrCodeDialog entityType="storage-containers" id={container.id} label="QR" />
}
/>
))}
@@ -80,25 +104,21 @@ export default function StoragePage() {
) : null}
{stockItems && stockItems.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{stockItems.map((item) => (
<ListItemCard
<EntityCard
key={item.id}
icon={Package}
gradient="var(--gradient-sky)"
title={item.name}
subtitle={item.category}
trailing={
<>
<span className="text-sm tabular-nums">
{item.quantity} {item.unit}
</span>
{item.minThreshold !== null && item.quantity < item.minThreshold ? (
<Badge variant="destructive">Niedrig</Badge>
) : null}
<RecordMovementDialog stockItemId={item.id} />
</>
badge={
item.minThreshold !== null && item.quantity < item.minThreshold ? (
<Badge variant="destructive">Niedrig</Badge>
) : undefined
}
fields={[{ label: "Bestand", value: `${item.quantity} ${item.unit}` }]}
actions={<RecordMovementDialog stockItemId={item.id} />}
/>
))}
</div>
+48 -48
View File
@@ -10,7 +10,8 @@ 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 { ListItemCard } from "@/components/list-item-card";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import {
Select,
SelectContent,
@@ -20,22 +21,18 @@ import {
} from "@/components/ui/select";
import { TaskEditDialog } from "./task-edit-dialog";
const STATUS_OPTIONS: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"];
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
const HISTORY_STATUSES: TaskStatus[] = ["DONE", "CANCELLED"];
export default function TasksPage() {
const queryClient = useQueryClient();
const [status, setStatus] = useState<TaskStatus | "">("");
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [priority, setPriority] = useState<TaskPriority | "">("");
const [onlyOverdue, setOnlyOverdue] = useState(false);
const { data: tasks, isLoading, isError } = useQuery({
queryKey: ["tasks", status, priority],
queryFn: () =>
fetchTasks({
status: status || undefined,
priority: priority || undefined,
}),
queryKey: ["tasks"],
queryFn: () => fetchTasks({ priority: priority || undefined }),
});
const completeMutation = useMutation({
@@ -46,7 +43,10 @@ export default function TasksPage() {
});
const [now] = useState(() => Date.now());
const visibleTasks = (tasks ?? []).filter((task) => {
const activeTasks = (tasks ?? []).filter((task) => !HISTORY_STATUSES.includes(task.status));
const historyTasks = (tasks ?? []).filter((task) => HISTORY_STATUSES.includes(task.status));
const bucketTasks = tab === "active" ? activeTasks : historyTasks;
const visibleTasks = bucketTasks.filter((task) => {
if (!onlyOverdue) return true;
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
});
@@ -66,22 +66,12 @@ export default function TasksPage() {
/>
<div className="flex flex-wrap items-center gap-3">
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as TaskStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={activeTasks.length}
historyCount={historyTasks.length}
/>
<Select
value={priority || "ALL"}
onValueChange={(value) =>
@@ -100,14 +90,16 @@ export default function TasksPage() {
))}
</SelectContent>
</Select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={onlyOverdue}
onChange={(event) => setOnlyOverdue(event.target.checked)}
/>
Nur überfällige
</label>
{tab === "active" ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={onlyOverdue}
onChange={(event) => setOnlyOverdue(event.target.checked)}
/>
Nur überfällige
</label>
) : null}
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -120,31 +112,39 @@ export default function TasksPage() {
{tasks && visibleTasks.length === 0 ? (
<EmptyState
icon={ListTodo}
title="Keine Aufgaben"
title={tab === "active" ? "Keine Aufgaben" : "Keine Historie"}
description={
onlyOverdue
? "Keine überfälligen Aufgaben – gut gemacht!"
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
tab === "active"
? onlyOverdue
? "Keine überfälligen Aufgaben – gut gemacht!"
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
}
/>
) : null}
{tasks && visibleTasks.length > 0 ? (
<div className="flex flex-col gap-2">
{visibleTasks.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleTasks.map((task) => (
<ListItemCard
<EntityCard
key={task.id}
icon={ListTodo}
gradient="var(--gradient-sky)"
title={task.title}
subtitle={`${task.priority} · Fällig: ${
task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—"
}`}
trailing={
badge={
<Badge variant={task.status === "DONE" ? "secondary" : "default"}>
{task.status}
</Badge>
}
fields={[
{ label: "Priorität", value: task.priority },
{
label: "Fällig",
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
},
]}
actions={
<>
<Badge variant={task.status === "DONE" ? "secondary" : "default"}>
{task.status}
</Badge>
<TaskEditDialog task={task} />
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
<Button
@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
export type EntityLifecycleTab = "active" | "history";
interface ActiveHistoryTabsProps {
value: EntityLifecycleTab;
onChange: (value: EntityLifecycleTab) => void;
activeCount?: number;
historyCount?: number;
}
export function ActiveHistoryTabs({
value,
onChange,
activeCount,
historyCount,
}: ActiveHistoryTabsProps) {
return (
<div
role="tablist"
className="inline-flex w-fit rounded-xl border border-border/60 bg-muted/40 p-1"
>
<button
type="button"
role="tab"
aria-selected={value === "active"}
onClick={() => onChange("active")}
className={cn(
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
value === "active"
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:text-foreground",
)}
>
Aktiv{activeCount !== undefined ? ` (${activeCount})` : ""}
</button>
<button
type="button"
role="tab"
aria-selected={value === "history"}
onClick={() => onChange("history")}
className={cn(
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
value === "history"
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:text-foreground",
)}
>
Historie{historyCount !== undefined ? ` (${historyCount})` : ""}
</button>
</div>
);
}
@@ -0,0 +1,83 @@
import { ReactNode } from "react";
import Link from "next/link";
import { type LucideIcon } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
interface EntityCardField {
label: string;
value: ReactNode;
}
interface EntityCardProps {
href?: string;
icon: LucideIcon;
gradient?: string;
title: ReactNode;
subtitle?: ReactNode;
badge?: ReactNode;
fields?: EntityCardField[];
actions?: ReactNode;
className?: string;
}
export function EntityCard({
href,
icon: Icon,
gradient = "var(--gradient-honey)",
title,
subtitle,
badge,
fields,
actions,
className,
}: EntityCardProps) {
const body = (
<Card interactive={Boolean(href)} className={className}>
<CardContent className="flex h-full flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: gradient }}
>
<Icon className="size-5" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{title}</p>
{subtitle ? (
<p className="truncate text-sm text-muted-foreground">{subtitle}</p>
) : null}
</div>
</div>
{badge ? <div className="shrink-0">{badge}</div> : null}
</div>
{fields && fields.length > 0 ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-2 border-t border-border/50 pt-3">
{fields.map((field) => (
<div key={field.label} className="min-w-0">
<p className="text-xs text-muted-foreground">{field.label}</p>
<p className="truncate text-sm font-medium">{field.value}</p>
</div>
))}
</div>
) : null}
{actions ? (
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/50 pt-3">
{actions}
</div>
) : null}
</CardContent>
</Card>
);
if (href) {
return (
<Link href={href} className="block h-full">
{body}
</Link>
);
}
return body;
}
@@ -1,56 +0,0 @@
import { ReactNode } from "react";
import Link from "next/link";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
interface ListItemCardProps {
href?: string;
icon: LucideIcon;
gradient?: string;
title: ReactNode;
subtitle?: ReactNode;
trailing?: ReactNode;
className?: string;
}
export function ListItemCard({
href,
icon: Icon,
gradient = "var(--gradient-honey)",
title,
subtitle,
trailing,
className,
}: ListItemCardProps) {
const body = (
<Card interactive={Boolean(href)} className={className}>
<CardContent className="flex items-center gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: gradient }}
>
<Icon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{title}</p>
{subtitle ? (
<p className="truncate text-sm text-muted-foreground">{subtitle}</p>
) : null}
</div>
{trailing ? <div className="flex shrink-0 items-center gap-2">{trailing}</div> : null}
{href ? (
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
) : null}
</CardContent>
</Card>
);
if (href) {
return (
<Link href={href} className="block">
{body}
</Link>
);
}
return body;
}