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
@@ -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