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