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

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

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

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

No files matched your search

+48 -48
View File
@@ -10,7 +10,8 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state";
import { ListItemCard } from "@/components/list-item-card";
import { EntityCard } from "@/components/entity-card";
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
import {
Select,
SelectContent,
@@ -20,22 +21,18 @@ import {
} from "@/components/ui/select";
import { TaskEditDialog } from "./task-edit-dialog";
const STATUS_OPTIONS: TaskStatus[] = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"];
const PRIORITY_OPTIONS: TaskPriority[] = ["LOW", "NORMAL", "HIGH", "URGENT"];
const HISTORY_STATUSES: TaskStatus[] = ["DONE", "CANCELLED"];
export default function TasksPage() {
const queryClient = useQueryClient();
const [status, setStatus] = useState<TaskStatus | "">("");
const [tab, setTab] = useState<EntityLifecycleTab>("active");
const [priority, setPriority] = useState<TaskPriority | "">("");
const [onlyOverdue, setOnlyOverdue] = useState(false);
const { data: tasks, isLoading, isError } = useQuery({
queryKey: ["tasks", status, priority],
queryFn: () =>
fetchTasks({
status: status || undefined,
priority: priority || undefined,
}),
queryKey: ["tasks"],
queryFn: () => fetchTasks({ priority: priority || undefined }),
});
const completeMutation = useMutation({
@@ -46,7 +43,10 @@ export default function TasksPage() {
});
const [now] = useState(() => Date.now());
const visibleTasks = (tasks ?? []).filter((task) => {
const activeTasks = (tasks ?? []).filter((task) => !HISTORY_STATUSES.includes(task.status));
const historyTasks = (tasks ?? []).filter((task) => HISTORY_STATUSES.includes(task.status));
const bucketTasks = tab === "active" ? activeTasks : historyTasks;
const visibleTasks = bucketTasks.filter((task) => {
if (!onlyOverdue) return true;
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
});
@@ -66,22 +66,12 @@ export default function TasksPage() {
/>
<div className="flex flex-wrap items-center gap-3">
<Select
value={status || "ALL"}
onValueChange={(value) => setStatus(value === "ALL" ? "" : ((value ?? "") as TaskStatus))}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status">{status || "Alle Status"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Status</SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<ActiveHistoryTabs
value={tab}
onChange={setTab}
activeCount={activeTasks.length}
historyCount={historyTasks.length}
/>
<Select
value={priority || "ALL"}
onValueChange={(value) =>
@@ -100,14 +90,16 @@ export default function TasksPage() {
))}
</SelectContent>
</Select>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={onlyOverdue}
onChange={(event) => setOnlyOverdue(event.target.checked)}
/>
Nur überfällige
</label>
{tab === "active" ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={onlyOverdue}
onChange={(event) => setOnlyOverdue(event.target.checked)}
/>
Nur überfällige
</label>
) : null}
</div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -120,31 +112,39 @@ export default function TasksPage() {
{tasks && visibleTasks.length === 0 ? (
<EmptyState
icon={ListTodo}
title="Keine Aufgaben"
title={tab === "active" ? "Keine Aufgaben" : "Keine Historie"}
description={
onlyOverdue
? "Keine überfälligen Aufgaben – gut gemacht!"
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
tab === "active"
? onlyOverdue
? "Keine überfälligen Aufgaben – gut gemacht!"
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
}
/>
) : null}
{tasks && visibleTasks.length > 0 ? (
<div className="flex flex-col gap-2">
{visibleTasks.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleTasks.map((task) => (
<ListItemCard
<EntityCard
key={task.id}
icon={ListTodo}
gradient="var(--gradient-sky)"
title={task.title}
subtitle={`${task.priority} · Fällig: ${
task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—"
}`}
trailing={
badge={
<Badge variant={task.status === "DONE" ? "secondary" : "default"}>
{task.status}
</Badge>
}
fields={[
{ label: "Priorität", value: task.priority },
{
label: "Fällig",
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
},
]}
actions={
<>
<Badge variant={task.status === "DONE" ? "secondary" : "default"}>
{task.status}
</Badge>
<TaskEditDialog task={task} />
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
<Button