feat(ui): detail pages for colonies, apiaries and queens with one clear main action

New DetailHeader: heading, back link, status as icon + word, ONE primary
action and a "Mehr" menu for exports, QR and the rest.

- Colony: "Durchsicht erfassen" is the main action; sub-cards show a
  load error with retry instead of "Noch keine …".
- Apiary: error states, weather with units.
- Queen: edit card in sections, evaluations as 1-5 tap scales.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 18:32:33 +02:00
1 parent 2bc0db6f33
commit 031e47eaad
20 files changed
+1300 -794

No files matched your search

@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Layers } from "lucide-react";
import { DetailHeader, DetailStatus } from "./detail-header";
describe("DetailHeader", () => {
it("shows the title as heading, a back link and the status as a word", () => {
render(
<DetailHeader
icon={Layers}
title="V-01"
status={<DetailStatus status="ACTIVE" label="Aktiv" />}
backHref="/colonies"
backLabel="Alle Völker"
/>,
);
expect(screen.getByRole("heading", { level: 1, name: "V-01" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Alle Völker" })).toHaveAttribute(
"href",
"/colonies",
);
expect(screen.getByText("Aktiv")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Mehr/ })).not.toBeInTheDocument();
});
it("renders the primary action and runs overflow actions from the Mehr menu", async () => {
const onSelect = vi.fn();
render(
<DetailHeader
icon={Layers}
title="V-01"
primaryAction={<button type="button">Durchsicht erfassen</button>}
moreActions={[
{ label: "Stockkarte (PDF)", onSelect },
{ label: "Neue Fütterung", href: "/feedings/new" },
]}
backHref="/colonies"
backLabel="Alle Völker"
/>,
);
expect(screen.getByRole("button", { name: "Durchsicht erfassen" })).toBeInTheDocument();
expect(screen.queryByText("Stockkarte (PDF)")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Mehr/ }));
expect(await screen.findByRole("menuitem", { name: "Neue Fütterung" })).toHaveAttribute(
"href",
"/feedings/new",
);
await userEvent.click(screen.getByRole("menuitem", { name: "Stockkarte (PDF)" }));
expect(onSelect).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,196 @@
"use client";
import { ReactNode } from "react";
import Link from "next/link";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
Circle,
XCircle,
type LucideIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ErrorState, LoadingState } from "@/components/query-state";
import { statusTone, type StatusTone } from "@/lib/status-tone";
export interface MoreAction {
label: string;
icon?: LucideIcon;
/** Either an action ... */
onSelect?: () => void;
/** ... or a place to go. */
href?: string;
disabled?: boolean;
}
interface DetailHeaderProps {
icon: LucideIcon;
title: string;
subtitle?: ReactNode;
/** Icon plus word, never colour alone - see DetailStatus. */
status?: ReactNode;
/** The ONE thing most people come to this page to do. */
primaryAction?: ReactNode;
/** Everything else, out of the way behind "Mehr". */
moreActions?: MoreAction[];
backHref: string;
backLabel: string;
}
/**
* Top of every detail page, always in the same order: way back, what this
* is and its state, then one obvious next step. Rarely used actions wait
* in "Mehr" so the page does not ask for a decision on arrival.
*/
export function DetailHeader({
icon: Icon,
title,
subtitle,
status,
primaryAction,
moreActions,
backHref,
backLabel,
}: DetailHeaderProps) {
const hasMore = moreActions !== undefined && moreActions.length > 0;
return (
<header className="flex flex-col gap-2">
<BackLink href={backHref} label={backLabel} />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Icon className="size-5" aria-hidden />
</span>
<div className="flex min-w-0 flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight break-words">
{title}
</h1>
{subtitle ? <p className="text-sm text-muted-foreground">{subtitle}</p> : null}
{status ? <div className="flex flex-wrap items-center gap-2">{status}</div> : null}
</div>
</div>
{primaryAction || hasMore ? (
<div className="flex w-full flex-wrap gap-2 sm:w-auto [&>*]:flex-1 sm:[&>*]:flex-none">
{hasMore ? <MoreMenu actions={moreActions} /> : null}
{primaryAction}
</div>
) : null}
</div>
</header>
);
}
function MoreMenu({ actions }: { actions: MoreAction[] }) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="touch" />}>
Mehr
<ChevronDown aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto min-w-56">
{actions.map((action) => {
const ItemIcon = action.icon;
const content = (
<>
{ItemIcon ? <ItemIcon aria-hidden /> : null}
{action.label}
</>
);
return action.href ? (
<DropdownMenuItem
key={action.label}
className="min-h-11 px-3"
disabled={action.disabled}
render={<Link href={action.href} />}
>
{content}
</DropdownMenuItem>
) : (
<DropdownMenuItem
key={action.label}
className="min-h-11 px-3"
disabled={action.disabled}
onClick={action.onSelect}
>
{content}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
const TONE_ICON: Record<StatusTone, LucideIcon> = {
default: CheckCircle2,
secondary: Circle,
outline: Circle,
warning: AlertTriangle,
destructive: XCircle,
};
/** A status as icon AND word, so it reads without telling colours apart. */
export function DetailStatus({ status, label }: { status: string; label: string }) {
const tone = statusTone(status);
const ToneIcon = TONE_ICON[tone];
return (
<Badge variant={tone} className="h-6 gap-1.5 px-2.5 text-sm">
<ToneIcon aria-hidden />
{label}
</Badge>
);
}
/** 44 px high: hit reliably with a gloved thumb. */
function BackLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="-ml-2 inline-flex min-h-11 items-center gap-1 self-start rounded-lg px-2 text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
>
<ChevronLeft className="size-5" aria-hidden />
{label}
</Link>
);
}
interface DetailQueryFallbackProps {
query: { isLoading: boolean; isFetching?: boolean; refetch: () => unknown };
/** Plural noun for the messages, e.g. "Volksdaten". */
what: string;
backHref: string;
backLabel: string;
}
/**
* While the record loads, or when it could not be loaded: the way back is
* already there, and a failure offers a retry instead of a dead end.
*/
export function DetailQueryFallback({ query, what, backHref, backLabel }: DetailQueryFallbackProps) {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<BackLink href={backHref} label={backLabel} />
{query.isLoading ? (
<LoadingState what={what} />
) : (
<ErrorState
what={what}
onRetry={() => void query.refetch()}
isRetrying={query.isFetching}
/>
)}
</main>
);
}