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:
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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user