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