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
+1055 -549

No files matched your search

@@ -1,24 +1,17 @@
"use client";
import { FormEvent, useState } from "react";
import { MapPin } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deactivateApiary, reactivateApiary, updateApiary } from "@/lib/apiaries/api";
import { ApiError } from "@/lib/auth/api-client";
import { Apiary } from "@/lib/apiaries/types";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ConfirmDialog } from "@/components/confirm-dialog";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Field } from "@/components/form/field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
const queryClient = useQueryClient();
@@ -29,6 +22,8 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
const [longitude, setLongitude] = useState(apiary.longitude?.toString() ?? "");
const [address, setAddress] = useState(apiary.address ?? "");
const [notes, setNotes] = useState(apiary.notes ?? "");
const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const updateMutation = useMutation({
mutationFn: () =>
@@ -43,8 +38,10 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
toast.success("Bienenstand gespeichert");
},
onError: () => toast.error("Bienenstand konnte nicht gespeichert werden"),
onError: (error) =>
setSubmitError(errorMessage(error, "Bienenstand konnte nicht gespeichert werden.")),
});
const deactivateMutation = useMutation({
@@ -56,9 +53,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
});
},
onError: (error) =>
toast.error(
error instanceof ApiError ? error.message : "Bienenstand konnte nicht deaktiviert werden",
),
toast.error(errorMessage(error, "Bienenstand konnte nicht deaktiviert werden.")),
});
const reactivateMutation = useMutation({
@@ -67,106 +62,120 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
toast.success("Bienenstand reaktiviert");
},
onError: () => toast.error("Bienenstand konnte nicht reaktiviert werden"),
onError: (error) =>
toast.error(errorMessage(error, "Bienenstand konnte nicht reaktiviert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
if (!name.trim()) {
setNameError("Bitte einen Namen angeben.");
return;
}
updateMutation.mutate();
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<MapPin className="size-4" />
</span>
<CardTitle>{apiary.name}</CardTitle>
</div>
<Badge variant={apiary.status === "ACTIVE" ? "default" : "secondary"}>
{apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
</Badge>
<CardHeader>
<CardTitle>Stammdaten</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-name">Name</Label>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<Field label="Name" required error={nameError}>
{({ id, describedBy, invalid }) => (
<Input
id="apiary-name"
id={id}
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={name}
onChange={(event) => setName(event.target.value)}
onChange={(event) => {
setName(event.target.value);
setNameError(null);
}}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-description">Beschreibung</Label>
)}
</Field>
<Field label="Beschreibung">
{({ id }) => (
<Textarea
id="apiary-description"
id={id}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-location">Standort</Label>
)}
</Field>
<Field label="Standort">
{({ id }) => (
<Input
id="apiary-location"
id={id}
className="h-11"
value={location}
onChange={(event) => setLocation(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="apiary-latitude">Breitengrad</Label>
)}
</Field>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Breitengrad" hint="z. B. 52.5200 – für Wetter & Tracht">
{({ id, describedBy }) => (
<Input
id="apiary-latitude"
id={id}
type="number"
step="0.000001"
inputMode="decimal"
placeholder="z. B. 52.5200"
className="h-11"
aria-describedby={describedBy}
value={latitude}
onChange={(event) => setLatitude(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="apiary-longitude">Längengrad</Label>
)}
</Field>
<Field label="Längengrad" hint="z. B. 13.4050">
{({ id, describedBy }) => (
<Input
id="apiary-longitude"
id={id}
type="number"
step="0.000001"
inputMode="decimal"
placeholder="z. B. 13.4050"
className="h-11"
aria-describedby={describedBy}
value={longitude}
onChange={(event) => setLongitude(event.target.value)}
/>
)}
</Field>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-address">Adresse</Label>
<Field label="Adresse">
{({ id }) => (
<Input
id="apiary-address"
id={id}
className="h-11"
value={address}
onChange={(event) => setAddress(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="apiary-notes">Notizen</Label>
<Textarea
id="apiary-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<div className="flex justify-between gap-2">
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Speichert…" : "Speichern"}
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
)}
</Field>
{submitError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{submitError}
</p>
) : null}
<div className="flex flex-wrap justify-between gap-2">
<Button type="submit" size="touch" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
{apiary.status === "ACTIVE" ? (
<ConfirmDialog
triggerLabel="Deaktivieren"
triggerSize="touch"
triggerDisabled={deactivateMutation.isPending}
title="Bienenstand deaktivieren?"
description={`„${apiary.name}“ wird deaktiviert. Die Historie bleibt erhalten und der Bienenstand kann jederzeit wieder reaktiviert werden.`}
@@ -177,6 +186,8 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
) : (
<Button
type="button"
size="touch"
variant="outline"
disabled={reactivateMutation.isPending}
onClick={() => reactivateMutation.mutate()}
>
@@ -3,23 +3,27 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { FileText, MapPin, Plus } from "lucide-react";
import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { Button } from "@/components/ui/button";
import { ApiaryEditCard } from "./apiary-edit-card";
import { WeatherCard } from "./weather-card";
const CURRENT_YEAR = new Date().getFullYear();
const BACK = { backHref: "/apiaries", backLabel: "Alle Bienenstände" };
export default function ApiaryDetailPage() {
const params = useParams<{ id: string }>();
const { data: apiary, isLoading, isError } = useQuery({
const apiaryQuery = useQuery({
queryKey: ["apiaries", params.id],
queryFn: () => fetchApiary(params.id),
});
const apiary = apiaryQuery.data;
const exportReportMutation = useMutation({
mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR),
@@ -31,48 +35,57 @@ export default function ApiaryDetailPage() {
onError: () => toast.error("Bestandsbuch konnte nicht erstellt werden. Bitte noch einmal versuchen."),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !apiary) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Bienenstand konnte nicht geladen werden.
</p>
);
if (apiaryQuery.isLoading || apiaryQuery.isError || !apiary) {
return <DetailQueryFallback query={apiaryQuery} what="Standdaten" {...BACK} />;
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex justify-end">
<QrCodeDialog entityType="apiaries" id={apiary.id} />
</div>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<ApiaryEditCard key={apiary.id} apiary={apiary} />
<div className="flex flex-wrap gap-2">
<DetailHeader
icon={MapPin}
title={apiary.name}
subtitle={apiary.location ?? undefined}
status={
<DetailStatus
status={apiary.status}
label={apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
/>
}
primaryAction={
<Button
size="touch"
nativeButton={false}
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
>
Neue Ernte
</Button>
<Button
variant="outline"
disabled={treatmentLogMutation.isPending}
onClick={() => treatmentLogMutation.mutate()}
>
{treatmentLogMutation.isPending ? "Wird erstellt…" : `Bestandsbuch ${CURRENT_YEAR}`}
</Button>
<Button
variant="outline"
disabled={exportReportMutation.isPending}
onClick={() => exportReportMutation.mutate()}
>
{exportReportMutation.isPending
? "Wird erstellt…"
: `Jahresbericht ${CURRENT_YEAR}`}
<Plus aria-hidden />
Ernte erfassen
</Button>
}
moreActions={[
{
label: treatmentLogMutation.isPending
? "Bestandsbuch wird erstellt…"
: `Bestandsbuch ${CURRENT_YEAR} (PDF)`,
icon: FileText,
disabled: treatmentLogMutation.isPending,
onSelect: () => treatmentLogMutation.mutate(),
},
{
label: exportReportMutation.isPending
? "Jahresbericht wird erstellt…"
: `Jahresbericht ${CURRENT_YEAR} (PDF)`,
icon: FileText,
disabled: exportReportMutation.isPending,
onSelect: () => exportReportMutation.mutate(),
},
]}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<ApiaryEditCard key={apiary.id} apiary={apiary} />
<div>
<QrCodeDialog entityType="apiaries" id={apiary.id} label="QR-Code für den Stand" />
</div>
</div>
<div className="flex flex-col gap-4">
@@ -6,6 +6,7 @@ import { fetchApiaryWeather, fetchTrachtprognose } from "@/lib/weather/api";
import { TrachtprognoseRating } from "@/lib/weather/types";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ErrorState, LoadingState } from "@/components/query-state";
const RATING_LABELS: Record<TrachtprognoseRating, string> = {
GUT: "Gute Tracht",
@@ -41,12 +42,12 @@ export function WeatherCard({
apiaryId: string;
hasCoordinates: boolean;
}) {
const { data: trachtprognose, isLoading: trachtLoading } = useQuery({
const trachtQuery = useQuery({
queryKey: ["apiaries", apiaryId, "trachtprognose"],
queryFn: () => fetchTrachtprognose(apiaryId),
enabled: hasCoordinates,
});
const { data: weather, isLoading: weatherLoading } = useQuery({
const weatherQuery = useQuery({
queryKey: ["apiaries", apiaryId, "weather"],
queryFn: () => fetchApiaryWeather(apiaryId),
enabled: hasCoordinates,
@@ -71,6 +72,8 @@ export function WeatherCard({
);
}
const trachtprognose = trachtQuery.data;
const weather = weatherQuery.data;
const today = new Date().toISOString().slice(0, 10);
const forecastDays = (weather?.daily ?? []).filter((day) => day.date >= today).slice(0, 7);
const todaySuitability = forecastDays[0]?.inspectionSuitable ?? null;
@@ -84,8 +87,14 @@ export function WeatherCard({
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{trachtLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
{trachtQuery.isLoading ? (
<LoadingState what="Trachtprognose-Daten" />
) : trachtQuery.isError ? (
<ErrorState
what="Trachtprognose-Daten"
onRetry={() => void trachtQuery.refetch()}
isRetrying={trachtQuery.isFetching}
/>
) : trachtprognose ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
@@ -102,8 +111,14 @@ export function WeatherCard({
</div>
) : null}
{weatherLoading ? (
<p className="text-sm text-muted-foreground">Wetterdaten laden…</p>
{weatherQuery.isLoading ? (
<LoadingState what="Wetterdaten" />
) : weatherQuery.isError ? (
<ErrorState
what="Wetterdaten"
onRetry={() => void weatherQuery.refetch()}
isRetrying={weatherQuery.isFetching}
/>
) : forecastDays.length > 0 ? (
<>
{todaySuitability !== null ? (
@@ -115,9 +130,9 @@ export function WeatherCard({
}`}
>
{todaySuitability ? (
<CheckCircle2 className="size-4.5 shrink-0" />
<CheckCircle2 className="size-4.5 shrink-0" aria-hidden />
) : (
<XCircle className="size-4.5 shrink-0" />
<XCircle className="size-4.5 shrink-0" aria-hidden />
)}
<span>
{todaySuitability
@@ -145,8 +160,9 @@ export function WeatherCard({
) : null}
{day.windSpeedMaxKmh !== null ? (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
<Wind className="size-3" />
{Math.round(day.windSpeedMaxKmh)}
<Wind className="size-3" aria-hidden />
<span className="sr-only">Wind bis</span>
{Math.round(day.windSpeedMaxKmh)} km/h
</span>
) : null}
</div>
@@ -1,7 +1,6 @@
"use client";
import { FormEvent, useState } from "react";
import { Layers } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateColony } from "@/lib/colonies/api";
@@ -17,7 +16,7 @@ import {
COLONY_TYPES,
COLONY_TYPE_LABELS,
} from "@/lib/colonies/labels";
import { statusTone } from "@/lib/status-tone";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -119,9 +118,10 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
void queryClient.invalidateQueries({ queryKey: ["colonies", colony.id] });
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
toast.success("Volk gespeichert");
},
onError: (error: Error) =>
toast.error(error.message || "Volk konnte nicht gespeichert werden"),
onError: (error) =>
toast.error(errorMessage(error, "Volk konnte nicht gespeichert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -131,17 +131,8 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Layers className="size-4" />
</span>
<CardTitle className="font-mono">{colony.code}</CardTitle>
</div>
<Badge variant={statusTone(colony.status)}>{COLONY_STATUS_LABELS[colony.status]}</Badge>
<CardHeader>
<CardTitle>Stammdaten</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
@@ -266,7 +257,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<Button type="submit" disabled={updateMutation.isPending}>
<Button type="submit" size="touch" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
@@ -7,11 +7,12 @@ import { fetchColonyFeedings, fetchColonyFeedingsSummary } from "@/lib/feedings/
import { FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FeedingEditDialog } from "./feeding-edit-dialog";
export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
const { data: feedings, isLoading } = useQuery({
const feedingsQuery = useQuery({
queryKey: ["colonies", colonyId, "feedings"],
queryFn: () => fetchColonyFeedings(colonyId),
});
@@ -22,10 +23,11 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Fütterung</CardTitle>
<Button
size="sm"
size="touch"
variant="outline"
nativeButton={false}
render={<Link href={`/feedings/new?colonyId=${colonyId}`} />}
>
@@ -41,15 +43,19 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
.join(" · ")}
</p>
) : null}
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!feedings || feedings.length === 0) ? (
<QueryState
query={feedingsQuery}
what="Fütterungen"
empty={
<EmptyState
icon={Utensils}
title="Noch keine Fütterung"
description="Erfasse die erste Fütterung für dieses Volk."
/>
) : null}
{feedings?.map((feeding) => (
}
>
{(feedings) =>
feedings.map((feeding) => (
<div key={feeding.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="font-medium">
@@ -63,7 +69,9 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) {
{feeding.product ? ` · ${feeding.product}` : ""}
</p>
</div>
))}
))
}
</QueryState>
</CardContent>
</Card>
);
@@ -3,7 +3,9 @@
import { History } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchColonyHistory } from "@/lib/colonies/api";
import type { ColonyHistory } from "@/lib/colonies/types";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import {
Card,
CardContent,
@@ -12,33 +14,43 @@ import {
} from "@/components/ui/card";
export function ColonyHistoryCard({ colonyId }: { colonyId: string }) {
const { data: history, isLoading } = useQuery({
const historyQuery = useQuery({
queryKey: ["colonies", colonyId, "history"],
queryFn: () => fetchColonyHistory(colonyId),
});
if (isLoading || !history) {
return <p className="text-sm text-muted-foreground">Historie lädt…</p>;
}
const hasHistory =
history.apiaryHistory.length > 0 ||
history.lineageAsSource.length > 0 ||
history.lineageAsResult.length > 0;
return (
<Card>
<CardHeader>
<CardTitle>Historie</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{!hasHistory ? (
<QueryState query={historyQuery} what="Einträge der Historie">
{(history) => <HistoryEntries history={history} />}
</QueryState>
</CardContent>
</Card>
);
}
function HistoryEntries({ history }: { history: ColonyHistory }) {
const hasHistory =
history.apiaryHistory.length > 0 ||
history.lineageAsSource.length > 0 ||
history.lineageAsResult.length > 0;
if (!hasHistory) {
return (
<EmptyState
icon={History}
title="Noch keine Historie"
description="Standortwechsel und Ableger erscheinen hier."
/>
) : null}
);
}
return (
<>
{history.apiaryHistory.map((entry) => (
<p key={entry.id}>
Standortwechsel am {new Date(entry.movedAt).toLocaleDateString("de-DE")}
@@ -57,7 +69,6 @@ export function ColonyHistoryCard({ colonyId }: { colonyId: string }) {
{new Date(entry.date).toLocaleDateString("de-DE")}
</p>
))}
</CardContent>
</Card>
</>
);
}
@@ -6,21 +6,23 @@ import { useQuery } from "@tanstack/react-query";
import { fetchColonyInspections } from "@/lib/inspections/api";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InspectionEditDialog } from "./inspection-edit-dialog";
export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
const { data: inspections, isLoading } = useQuery({
const inspectionsQuery = useQuery({
queryKey: ["colonies", colonyId, "inspections"],
queryFn: () => fetchColonyInspections(colonyId),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Durchsichten</CardTitle>
<Button
size="sm"
size="touch"
variant="outline"
nativeButton={false}
render={<Link href={`/inspections/new?colonyId=${colonyId}`} />}
>
@@ -28,15 +30,19 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!inspections || inspections.length === 0) ? (
<QueryState
query={inspectionsQuery}
what="Durchsichten"
empty={
<EmptyState
icon={ClipboardCheck}
title="Noch keine Durchsicht"
description="Dokumentiere die erste Durchsicht für dieses Volk."
/>
) : null}
{inspections?.map((inspection) => (
}
>
{(inspections) =>
inspections.map((inspection) => (
<div key={inspection.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="font-medium">
@@ -56,7 +62,9 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
{inspection.varroaIssues ? <p>Varroa: {inspection.varroaIssues}</p> : null}
{inspection.measures ? <p>Maßnahmen: {inspection.measures}</p> : null}
</div>
))}
))
}
</QueryState>
</CardContent>
</Card>
);
@@ -8,21 +8,23 @@ import { SWARM_ACTION_LABELS, SWARM_MOOD_LABELS } from "@/lib/swarm-control/labe
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SwarmControlEditDialog } from "./swarm-control-edit-dialog";
export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
const { data: checks, isLoading } = useQuery({
const checksQuery = useQuery({
queryKey: ["colonies", colonyId, "swarm-control-checks"],
queryFn: () => fetchColonySwarmControlChecks(colonyId),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Schwarmkontrolle</CardTitle>
<Button
size="sm"
size="touch"
variant="outline"
nativeButton={false}
render={<Link href={`/swarm-control/new?colonyId=${colonyId}`} />}
>
@@ -30,15 +32,19 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!checks || checks.length === 0) ? (
<QueryState
query={checksQuery}
what="Schwarmkontrollen"
empty={
<EmptyState
icon={Sprout}
title="Noch keine Schwarmkontrolle"
description="Erfasse die erste Weiselzellenkontrolle für dieses Volk."
/>
) : null}
{checks?.map((check) => (
}
>
{(checks) =>
checks.map((check) => (
<div key={check.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="font-medium">{new Date(check.date).toLocaleDateString("de-DE")}</p>
@@ -59,11 +65,15 @@ export function ColonySwarmControlCard({ colonyId }: { colonyId: string }) {
<p>Maßnahme: {SWARM_ACTION_LABELS[check.actionTaken]}</p>
) : null}
{check.nextCheckDate ? (
<p>Nächste Kontrolle: {new Date(check.nextCheckDate).toLocaleDateString("de-DE")}</p>
<p>
Nächste Kontrolle: {new Date(check.nextCheckDate).toLocaleDateString("de-DE")}
</p>
) : null}
{check.notes ? <p>{check.notes}</p> : null}
</div>
))}
))
}
</QueryState>
</CardContent>
</Card>
);
@@ -6,21 +6,23 @@ import { useQuery } from "@tanstack/react-query";
import { fetchColonyTreatments } from "@/lib/treatments/api";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TreatmentEditDialog } from "./treatment-edit-dialog";
export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
const { data: treatments, isLoading } = useQuery({
const treatmentsQuery = useQuery({
queryKey: ["colonies", colonyId, "treatments"],
queryFn: () => fetchColonyTreatments(colonyId),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Behandlungen</CardTitle>
<Button
size="sm"
size="touch"
variant="outline"
nativeButton={false}
render={<Link href={`/treatments/new?colonyId=${colonyId}`} />}
>
@@ -28,15 +30,19 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!treatments || treatments.length === 0) ? (
<QueryState
query={treatmentsQuery}
what="Behandlungen"
empty={
<EmptyState
icon={Syringe}
title="Noch keine Behandlung"
description="Dokumentiere die erste Behandlung für dieses Volk."
/>
) : null}
{treatments?.map((treatment) => (
}
>
{(treatments) =>
treatments.map((treatment) => (
<div key={treatment.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="font-medium">
@@ -53,7 +59,9 @@ export function ColonyTreatmentsCard({ colonyId }: { colonyId: string }) {
<p>Wartezeit: {treatment.withdrawalPeriodDays} Tag(e)</p>
) : null}
</div>
))}
))
}
</QueryState>
</CardContent>
</Card>
);
@@ -7,6 +7,7 @@ import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { VarroaEditDialog } from "./varroa-edit-dialog";
@@ -44,21 +45,18 @@ function VarroaTrendChart({ values }: { values: number[] }) {
}
export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
const { data: measurements, isLoading } = useQuery({
const measurementsQuery = useQuery({
queryKey: ["colonies", colonyId, "varroa-measurements"],
queryFn: () => fetchColonyVarroaMeasurements(colonyId),
});
const values = (measurements ?? [])
.map((measurement) => measurement.miteCount ?? measurement.calculatedValue)
.filter((value): value is number => value !== null);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Varroa</CardTitle>
<Button
size="sm"
size="touch"
variant="outline"
nativeButton={false}
render={<Link href={`/varroa/new?colonyId=${colonyId}`} />}
>
@@ -66,16 +64,25 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!measurements || measurements.length === 0) ? (
<QueryState
query={measurementsQuery}
what="Varroamessungen"
empty={
<EmptyState
icon={Bug}
title="Noch keine Varroamessung"
description="Erfasse die erste Messung für dieses Volk."
/>
) : null}
<VarroaTrendChart values={values} />
{measurements?.map((measurement) => (
}
>
{(measurements) => (
<>
<VarroaTrendChart
values={measurements
.map((measurement) => measurement.miteCount ?? measurement.calculatedValue)
.filter((value): value is number => value !== null)}
/>
{measurements.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<p className="font-medium">
@@ -93,6 +100,9 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
</div>
))}
</>
)}
</QueryState>
</CardContent>
</Card>
);
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ColonyDetailPage from "./page";
import { fetchColony } from "@/lib/colonies/api";
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "colony-1" }),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn() }));
vi.mock("@/lib/export/api", () => ({
exportColonyAnnualReportPdf: vi.fn(),
exportColonyRecordCardPdf: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ColonyDetailPage />
</QueryClientProvider>,
);
}
describe("ColonyDetailPage – failed load", () => {
beforeEach(() => {
vi.mocked(fetchColony).mockReset();
});
it("explains the failure, offers a way back and retries on request", async () => {
vi.mocked(fetchColony).mockRejectedValue(new Error("offline"));
renderPage();
const retry = await screen.findByRole("button", { name: "Erneut versuchen" });
expect(screen.getByRole("alert")).toHaveTextContent(
"Volksdaten konnten nicht geladen werden.",
);
expect(screen.getByRole("link", { name: "Alle Völker" })).toHaveAttribute(
"href",
"/colonies",
);
expect(fetchColony).toHaveBeenCalledTimes(1);
await userEvent.click(retry);
expect(fetchColony).toHaveBeenCalledTimes(2);
});
});
@@ -1,12 +1,16 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ClipboardCheck, FileText, Layers } from "lucide-react";
import { toast } from "sonner";
import { fetchColony } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels";
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { Button } from "@/components/ui/button";
import { ColonyEditCard } from "./colony-edit-card";
import { ColonyActions } from "./colony-actions";
@@ -18,13 +22,15 @@ import { ColonyTreatmentsCard } from "./colony-treatments";
import { ColonyFeedingsCard } from "./colony-feedings";
const CURRENT_YEAR = new Date().getFullYear();
const BACK = { backHref: "/colonies", backLabel: "Alle Völker" };
export default function ColonyDetailPage() {
const params = useParams<{ id: string }>();
const { data: colony, isLoading, isError } = useQuery({
const colonyQuery = useQuery({
queryKey: ["colonies", params.id],
queryFn: () => fetchColony(params.id),
});
const colony = colonyQuery.data;
const exportReportMutation = useMutation({
mutationFn: () => exportColonyAnnualReportPdf(params.id, CURRENT_YEAR),
@@ -36,42 +42,52 @@ export default function ColonyDetailPage() {
onError: () => toast.error("Stockkarte konnte nicht erstellt werden. Bitte noch einmal versuchen."),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !colony) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Volk konnte nicht geladen werden.
</p>
);
if (colonyQuery.isLoading || colonyQuery.isError || !colony) {
return <DetailQueryFallback query={colonyQuery} what="Volksdaten" {...BACK} />;
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex flex-wrap items-center justify-end gap-2">
<DetailHeader
icon={Layers}
title={colony.code}
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />}
primaryAction={
<Button
variant="outline"
disabled={recordCardMutation.isPending}
onClick={() => recordCardMutation.mutate()}
size="touch"
nativeButton={false}
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
>
{recordCardMutation.isPending ? "Wird erstellt…" : "Stockkarte (PDF)"}
<ClipboardCheck aria-hidden />
Durchsicht erfassen
</Button>
<Button
variant="outline"
disabled={exportReportMutation.isPending}
onClick={() => exportReportMutation.mutate()}
>
{exportReportMutation.isPending
? "Wird erstellt…"
: `Jahresbericht ${CURRENT_YEAR}`}
</Button>
<QrCodeDialog entityType="colonies" id={colony.id} />
</div>
}
moreActions={[
{
label: recordCardMutation.isPending ? "Stockkarte wird erstellt…" : "Stockkarte (PDF)",
icon: FileText,
disabled: recordCardMutation.isPending,
onSelect: () => recordCardMutation.mutate(),
},
{
label: exportReportMutation.isPending
? "Jahresbericht wird erstellt…"
: `Jahresbericht ${CURRENT_YEAR} (PDF)`,
icon: FileText,
disabled: exportReportMutation.isPending,
onSelect: () => exportReportMutation.mutate(),
},
]}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1">
<ColonyEditCard key={colony.id} colony={colony} />
<ColonyActions colony={colony} />
<div>
<QrCodeDialog entityType="colonies" id={colony.id} label="QR-Code für die Beute" />
</div>
</div>
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
@@ -4,7 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { assignQueenColony } from "@/lib/queens/api";
import { ApiError } from "@/lib/auth/api-client";
import { errorMessage } from "@/lib/forms/error-message";
import { QUEEN_UNAVAILABLE_STATUSES } from "@/lib/queens/labels";
import { QueenStatus } from "@/lib/queens/types";
import { fetchColonies } from "@/lib/colonies/api";
@@ -44,19 +44,19 @@ export function AssignColonyDialog({
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["queens", queenId] });
void queryClient.invalidateQueries({ queryKey: ["queens", queenId, "history"] });
const colony = colonies?.find((candidate) => candidate.id === colonyId);
toast.success(colony ? `Königin sitzt jetzt in ${colony.code}` : "Volk zugewiesen");
setOpen(false);
},
onError: (error) =>
toast.error(
error instanceof ApiError ? error.message : "Volk konnte nicht zugewiesen werden",
),
toast.error(errorMessage(error, "Volk konnte nicht zugewiesen werden.")),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="outline" disabled={unavailable}>
<Button size="touch" disabled={unavailable}>
Volk zuweisen
</Button>
}
@@ -75,7 +75,7 @@ export function AssignColonyDialog({
<div className="flex flex-col gap-2">
<Label htmlFor="assign-colony">Volk</Label>
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? "")}>
<SelectTrigger id="assign-colony">
<SelectTrigger id="assign-colony" className="h-11 w-full">
<SelectValue placeholder="Volk wählen" />
</SelectTrigger>
<SelectContent>
@@ -90,8 +90,8 @@ export function AssignColonyDialog({
</Select>
</div>
<DialogFooter>
<Button type="submit" disabled={!colonyId || mutation.isPending}>
Zuweisen
<Button type="submit" size="touch" disabled={!colonyId || mutation.isPending}>
{mutation.isPending ? "Wird zugewiesen…" : "Zuweisen"}
</Button>
</DialogFooter>
</form>
@@ -2,42 +2,48 @@
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Crown } from "lucide-react";
import { fetchQueen } from "@/lib/queens/api";
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { QueenEditCard } from "./queen-edit-card";
import { AssignColonyDialog } from "./assign-colony-dialog";
import { QueenHistoryCard } from "./queen-history";
import { QueenEvaluationsCard } from "./queen-evaluations";
import { QueenLineageCard } from "./queen-lineage";
const BACK = { backHref: "/queens", backLabel: "Alle Königinnen" };
export default function QueenDetailPage() {
const params = useParams<{ id: string }>();
const { data: queen, isLoading, isError } = useQuery({
const queenQuery = useQuery({
queryKey: ["queens", params.id],
queryFn: () => fetchQueen(params.id),
});
const queen = queenQuery.data;
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !queen) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Königin konnte nicht geladen werden.
</p>
);
if (queenQuery.isLoading || queenQuery.isError || !queen) {
return <DetailQueryFallback query={queenQuery} what="Daten der Königin" {...BACK} />;
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<div className="flex justify-end">
<QrCodeDialog entityType="queens" id={queen.id} />
</div>
<DetailHeader
icon={Crown}
title={queen.code}
subtitle={queen.hatchYear ? `Schlupfjahr ${queen.hatchYear}` : undefined}
status={<DetailStatus status={queen.status} label={QUEEN_STATUS_LABELS[queen.status]} />}
primaryAction={<AssignColonyDialog queenId={queen.id} queenStatus={queen.status} />}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1">
<QueenEditCard key={queen.id} queen={queen} />
<AssignColonyDialog queenId={queen.id} queenStatus={queen.status} />
<div>
<QrCodeDialog entityType="queens" id={queen.id} />
</div>
</div>
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
@@ -1,18 +1,17 @@
"use client";
import { FormEvent, useState } from "react";
import { Crown } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchQueens, updateQueen } from "@/lib/queens/api";
import { suggestMarkingColorForYear } from "@/lib/queens/marking-color";
import { Queen, QueenStatus } from "@/lib/queens/types";
import { QUEEN_STATUSES, QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { statusTone } from "@/lib/status-tone";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@@ -44,6 +43,7 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
const [motherQueenId, setMotherQueenId] = useState(queen.motherQueenId ?? "");
const [notes, setNotes] = useState(queen.notes ?? "");
const [status, setStatus] = useState<QueenStatus>(queen.status);
const [submitError, setSubmitError] = useState<string | null>(null);
function handleHatchYearChange(value: string) {
setHatchYear(value);
@@ -80,130 +80,128 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["queens"] });
void queryClient.invalidateQueries({ queryKey: ["queens", queen.id] });
toast.success("Königin gespeichert");
},
onError: () => toast.error("Königin konnte nicht gespeichert werden"),
onError: (error) =>
setSubmitError(errorMessage(error, "Königin konnte nicht gespeichert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
mutation.mutate();
}
const hasOrigin = [breed, origin, breeder, motherLine, fatherLine, motherQueenId].some(Boolean);
const hasMating = [matingType, matingStatus, inseminationData].some(Boolean);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-sage)" }}
>
<Crown className="size-4" />
</span>
<CardTitle className="font-mono">{queen.code}</CardTitle>
</div>
<Badge variant={statusTone(queen.status)}>{QUEEN_STATUS_LABELS[queen.status]}</Badge>
<CardHeader>
<CardTitle>Stammdaten</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-hatch-year">Schlupfjahr</Label>
<form className="flex flex-col gap-6" onSubmit={handleSubmit}>
<FormSection step={1} title="Status & Zeichnung">
<Field label="Status" required>
{({ id }) => (
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue>{QUEEN_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger>
<SelectContent>
{QUEEN_STATUSES.map((option) => (
<SelectItem key={option} value={option}>
{QUEEN_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</Field>
<Field label="Schlupfjahr" hint="Die Zeichenfarbe wird passend vorgeschlagen.">
{({ id, describedBy }) => (
<Input
id="queen-hatch-year"
id={id}
type="number"
inputMode="numeric"
className="h-11"
aria-describedby={describedBy}
value={hatchYear}
onChange={(event) => handleHatchYearChange(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-breed">Zucht/Rasse</Label>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Zeichenfarbe">
{({ id }) => (
<Input
id="queen-breed"
value={breed}
onChange={(event) => setBreed(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-origin">Herkunft</Label>
<Input
id="queen-origin"
value={origin}
onChange={(event) => setOrigin(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-breeder">Züchter</Label>
<Input
id="queen-breeder"
value={breeder}
onChange={(event) => setBreeder(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-marking-color">Zeichenfarbe</Label>
<Input
id="queen-marking-color"
id={id}
className="h-11"
value={markingColor}
onChange={(event) => setMarkingColor(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-marking-number">Zeichennummer</Label>
)}
</Field>
<Field label="Zeichennummer">
{({ id }) => (
<Input
id="queen-marking-number"
id={id}
className="h-11"
value={markingNumber}
onChange={(event) => setMarkingNumber(event.target.value)}
/>
)}
</Field>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-mating-type">Begattungsart</Label>
</FormSection>
<FormSection
step={2}
title="Herkunft & Zucht"
collapsible
defaultOpen={hasOrigin}
fieldCount={6}
>
<Field label="Zucht/Rasse">
{({ id }) => (
<Input
id="queen-mating-type"
value={matingType}
onChange={(event) => setMatingType(event.target.value)}
id={id}
className="h-11"
value={breed}
onChange={(event) => setBreed(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-mating-status">Begattungsstatus</Label>
)}
</Field>
<Field label="Herkunft">
{({ id }) => (
<Input
id="queen-mating-status"
value={matingStatus}
onChange={(event) => setMatingStatus(event.target.value)}
id={id}
className="h-11"
value={origin}
onChange={(event) => setOrigin(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-insemination-data">Besamungsdaten</Label>
)}
</Field>
<Field label="Züchter">
{({ id }) => (
<Input
id="queen-insemination-data"
value={inseminationData}
onChange={(event) => setInseminationData(event.target.value)}
id={id}
className="h-11"
value={breeder}
onChange={(event) => setBreeder(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-mother-line">Mutterlinie</Label>
<Input
id="queen-mother-line"
value={motherLine}
onChange={(event) => setMotherLine(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-father-line">Vaterlinie</Label>
<Input
id="queen-father-line"
value={fatherLine}
onChange={(event) => setFatherLine(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-mother">Mutter</Label>
)}
</Field>
<Field label="Mutter">
{({ id }) => (
<Select
value={motherQueenId || "NONE"}
onValueChange={(value) =>
setMotherQueenId(value === "NONE" ? "" : (value ?? ""))
}
>
<SelectTrigger id="queen-mother" className="w-full">
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Keine Mutter hinterlegt">
{motherQueenId
? motherOptions.find((option) => option.id === motherQueenId)?.code
@@ -219,32 +217,84 @@ export function QueenEditCard({ queen }: { queen: Queen }) {
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-status">Status</Label>
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
<SelectTrigger id="queen-status">
<SelectValue>{QUEEN_STATUS_LABELS[status]}</SelectValue>
</SelectTrigger>
<SelectContent>
{QUEEN_STATUSES.map((option) => (
<SelectItem key={option} value={option}>
{QUEEN_STATUS_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="queen-notes">Notizen</Label>
<Textarea
id="queen-notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
)}
</Field>
<Field label="Mutterlinie">
{({ id }) => (
<Input
id={id}
className="h-11"
value={motherLine}
onChange={(event) => setMotherLine(event.target.value)}
/>
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
)}
</Field>
<Field label="Vaterlinie">
{({ id }) => (
<Input
id={id}
className="h-11"
value={fatherLine}
onChange={(event) => setFatherLine(event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection
step={3}
title="Begattung"
collapsible
defaultOpen={hasMating}
fieldCount={3}
>
<Field label="Begattungsart">
{({ id }) => (
<Input
id={id}
className="h-11"
value={matingType}
onChange={(event) => setMatingType(event.target.value)}
/>
)}
</Field>
<Field label="Begattungsstatus">
{({ id }) => (
<Input
id={id}
className="h-11"
value={matingStatus}
onChange={(event) => setMatingStatus(event.target.value)}
/>
)}
</Field>
<Field label="Besamungsdaten">
{({ id }) => (
<Input
id={id}
className="h-11"
value={inseminationData}
onChange={(event) => setInseminationData(event.target.value)}
/>
)}
</Field>
</FormSection>
<FormSection step={4} title="Notizen">
<Field label="Notizen">
{({ id }) => (
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
)}
</Field>
</FormSection>
{submitError ? (
<p role="alert" className="text-sm font-medium text-destructive">
{submitError}
</p>
) : null}
<Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</form>
</CardContent>
@@ -1,17 +1,18 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil, Star } from "lucide-react";
import { Star } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
addQueenEvaluation,
fetchQueenEvaluations,
updateQueenEvaluation,
} from "@/lib/queens/api";
import { CreateQueenEvaluationInput, QueenEvaluation } from "@/lib/queens/types";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { fetchColonies } from "@/lib/colonies/api";
import {
@@ -22,17 +23,27 @@ import {
SelectValue,
} from "@/components/ui/select";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import { Field } from "@/components/form/field";
import { RatingScale } from "@/components/form/rating-scale";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DATE_REQUIRED_MESSAGE,
DialogSubmitBar,
EditTriggerButton,
} from "../../colonies/[id]/edit-dialog-parts";
const CRITERIA: { key: keyof Omit<CreateQueenEvaluationInput, "date" | "colonyId" | "notes">; label: string }[] = [
type CriterionKey = keyof Omit<CreateQueenEvaluationInput, "date" | "colonyId" | "notes">;
type Scores = Record<CriterionKey, number | null>;
const CRITERIA: { key: CriterionKey; label: string }[] = [
{ key: "colonyStrength", label: "Volksstärke" },
{ key: "gentleness", label: "Sanftmut" },
{ key: "combBuild", label: "Wabensitz" },
@@ -41,6 +52,9 @@ const CRITERIA: { key: keyof Omit<CreateQueenEvaluationInput, "date" | "colonyId
{ key: "honeyYield", label: "Honigertrag" },
];
const DEFAULT_SCORE = 3;
const RATING_REQUIRED_MESSAGE = "Bitte einen Wert von 1 bis 5 antippen.";
/** Sentinel for "no colony": the rating may stand on its own, and an empty
* select value would render the placeholder rather than a choosable entry. */
const NO_COLONY = "__none__";
@@ -49,6 +63,123 @@ function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
function defaultScores(): Scores {
return Object.fromEntries(CRITERIA.map(({ key }) => [key, DEFAULT_SCORE])) as Scores;
}
function scoresFrom(evaluation: QueenEvaluation): Scores {
return Object.fromEntries(CRITERIA.map(({ key }) => [key, evaluation[key]])) as Scores;
}
function missingScores(scores: Scores): CriterionKey[] {
return CRITERIA.filter(({ key }) => scores[key] === null).map(({ key }) => key);
}
/** Only call once missingScores() is empty. */
function toScoreInput(scores: Scores): Record<CriterionKey, number> {
return Object.fromEntries(CRITERIA.map(({ key }) => [key, scores[key] ?? DEFAULT_SCORE])) as Record<
CriterionKey,
number
>;
}
interface EvaluationFieldsProps {
date: string;
onDateChange: (date: string) => void;
dateError: string | null;
scores: Scores;
onScoreChange: (key: CriterionKey, value: number | null) => void;
missing: CriterionKey[];
}
/** Date plus the six 1–5 ratings, each as five large buttons. */
function EvaluationFields({
date,
onDateChange,
dateError,
scores,
onScoreChange,
missing,
}: EvaluationFieldsProps) {
return (
<>
<Field label="Datum" required error={dateError}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={date}
onChange={(event) => onDateChange(event.target.value)}
/>
)}
</Field>
{CRITERIA.map((criterion) => (
<Field
key={criterion.key}
label={criterion.label}
required
error={missing.includes(criterion.key) ? RATING_REQUIRED_MESSAGE : null}
>
{({ id, labelId, describedBy }) => (
<RatingScale
id={id}
labelledBy={labelId}
describedBy={describedBy}
value={scores[criterion.key]}
onChange={(value) => onScoreChange(criterion.key, value)}
lowLabel="schlecht"
highLabel="sehr gut"
/>
)}
</Field>
))}
</>
);
}
function useEvaluationForm(initialDate: string, initialScores: Scores) {
const [date, setDate] = useState(initialDate);
const [scores, setScores] = useState<Scores>(initialScores);
const [dateError, setDateError] = useState<string | null>(null);
const [missing, setMissing] = useState<CriterionKey[]>([]);
return {
date,
scores,
fieldProps: {
date,
onDateChange: (next: string) => {
setDate(next);
setDateError(null);
},
dateError,
scores,
onScoreChange: (key: CriterionKey, value: number | null) => {
setScores((current) => ({ ...current, [key]: value }));
setMissing((current) => current.filter((entry) => entry !== key));
},
missing,
},
/** Shows what is missing next to each field; true when all is there. */
validate: (): boolean => {
const nextMissing = missingScores(scores);
setMissing(nextMissing);
setDateError(date ? null : DATE_REQUIRED_MESSAGE);
return Boolean(date) && nextMissing.length === 0;
},
reset: () => {
setDate(initialDate);
setScores(initialScores);
setDateError(null);
setMissing([]);
},
};
}
/**
* Corrects a rating after the fact. Breeding decisions are made from these
* scores, so a slipped digit has to be fixable rather than left standing.
@@ -62,15 +193,7 @@ function EditEvaluationDialog({
}) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(evaluation.date.slice(0, 10));
const [scores, setScores] = useState<Record<string, string>>(
Object.fromEntries(
CRITERIA.map((criterion) => [
criterion.key,
String(evaluation[criterion.key]),
]),
),
);
const form = useEvaluationForm(evaluation.date.slice(0, 10), scoresFrom(evaluation));
const [colonyId, setColonyId] = useState(evaluation.colonyId ?? NO_COLONY);
const [notes, setNotes] = useState(evaluation.notes ?? "");
const [error, setError] = useState<string | null>(null);
@@ -83,13 +206,8 @@ function EditEvaluationDialog({
const mutation = useMutation({
mutationFn: () =>
updateQueenEvaluation(evaluation.id, {
date: new Date(date).toISOString(),
colonyStrength: Number(scores.colonyStrength),
gentleness: Number(scores.gentleness),
combBuild: Number(scores.combBuild),
swarmingTendency: Number(scores.swarmingTendency),
broodPattern: Number(scores.broodPattern),
honeyYield: Number(scores.honeyYield),
date: new Date(form.date).toISOString(),
...toScoreInput(form.scores),
colonyId: colonyId === NO_COLONY ? undefined : colonyId,
notes: notes || undefined,
}),
@@ -97,77 +215,34 @@ function EditEvaluationDialog({
void queryClient.invalidateQueries({
queryKey: ["queens", queenId, "evaluations"],
});
toast.success("Bewertung gespeichert");
setOpen(false);
setError(null);
},
onError: (mutationError: Error) =>
setError(
mutationError.message || "Bewertung konnte nicht gespeichert werden.",
),
onError: (mutationError) =>
setError(errorMessage(mutationError, "Bewertung konnte nicht gespeichert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!form.validate()) return;
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogTrigger render={<EditTriggerButton />} />
<DialogContent>
<DialogHeader>
<DialogTitle>Bewertung bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor={`evaluation-edit-date-${evaluation.id}`}>Datum</Label>
<Input
id={`evaluation-edit-date-${evaluation.id}`}
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
{CRITERIA.map((criterion) => (
<div key={criterion.key} className="flex flex-col gap-2">
<Label htmlFor={`evaluation-edit-${criterion.key}-${evaluation.id}`}>
{criterion.label} (1–5)
</Label>
<Input
id={`evaluation-edit-${criterion.key}-${evaluation.id}`}
type="number"
min={1}
max={5}
required
value={scores[criterion.key]}
onChange={(event) =>
setScores((prev) => ({
...prev,
[criterion.key]: event.target.value,
}))
}
/>
</div>
))}
<div className="flex flex-col gap-2">
<Label htmlFor={`evaluation-edit-colony-${evaluation.id}`}>Volk</Label>
<Select
value={colonyId}
onValueChange={(value) => setColonyId(value ?? NO_COLONY)}
>
<SelectTrigger
id={`evaluation-edit-colony-${evaluation.id}`}
className="w-full"
>
<form className="flex flex-col gap-5" onSubmit={handleSubmit} noValidate>
<EvaluationFields {...form.fieldProps} />
<Field label="Volk">
{({ id }) => (
<Select value={colonyId} onValueChange={(value) => setColonyId(value ?? NO_COLONY)}>
<SelectTrigger id={id} className="h-11 w-full">
<SelectValue placeholder="Kein Volk" />
</SelectTrigger>
<SelectContent>
@@ -179,27 +254,65 @@ function EditEvaluationDialog({
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`evaluation-edit-notes-${evaluation.id}`}>
Notizen
</Label>
<Textarea
id={`evaluation-edit-notes-${evaluation.id}`}
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
)}
</Field>
<DialogSubmitBar isPending={mutation.isPending} error={error} />
</form>
</DialogContent>
</Dialog>
);
}
function AddEvaluationDialog({ queenId }: { queenId: string }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const form = useEvaluationForm(todayIsoDate(), defaultScores());
const [error, setError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (input: CreateQueenEvaluationInput) => addQueenEvaluation(queenId, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["queens", queenId, "evaluations"] });
toast.success("Bewertung gespeichert");
setOpen(false);
setError(null);
form.reset();
},
onError: (mutationError) =>
setError(errorMessage(mutationError, "Bewertung konnte nicht gespeichert werden.")),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!form.validate()) return;
mutation.mutate({
date: new Date(form.date).toISOString(),
...toScoreInput(form.scores),
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="touch" variant="outline">
Neue Bewertung
</Button>
</DialogFooter>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Königin bewerten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit} noValidate>
<EvaluationFields {...form.fieldProps} />
<DialogSubmitBar isPending={mutation.isPending} error={error} />
</form>
</DialogContent>
</Dialog>
@@ -207,113 +320,31 @@ function EditEvaluationDialog({
}
export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [date, setDate] = useState(todayIsoDate());
const [scores, setScores] = useState<Record<string, string>>({
colonyStrength: "3",
gentleness: "3",
combBuild: "3",
swarmingTendency: "3",
broodPattern: "3",
honeyYield: "3",
});
const [error, setError] = useState<string | null>(null);
const { data: evaluations, isLoading } = useQuery({
const evaluationsQuery = useQuery({
queryKey: ["queens", queenId, "evaluations"],
queryFn: () => fetchQueenEvaluations(queenId),
});
const mutation = useMutation({
mutationFn: (input: CreateQueenEvaluationInput) => addQueenEvaluation(queenId, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["queens", queenId, "evaluations"] });
setOpen(false);
setError(null);
},
onError: () => {
setError("Bewertung konnte nicht gespeichert werden.");
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({
date: new Date(date).toISOString(),
colonyStrength: Number(scores.colonyStrength),
gentleness: Number(scores.gentleness),
combBuild: Number(scores.combBuild),
swarmingTendency: Number(scores.swarmingTendency),
broodPattern: Number(scores.broodPattern),
honeyYield: Number(scores.honeyYield),
});
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Bewertung</CardTitle>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Neue Bewertung</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Königin bewerten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="evaluation-date">Datum</Label>
<Input
id="evaluation-date"
type="date"
required
value={date}
onChange={(event) => setDate(event.target.value)}
/>
</div>
{CRITERIA.map((criterion) => (
<div key={criterion.key} className="flex flex-col gap-2">
<Label htmlFor={`evaluation-${criterion.key}`}>
{criterion.label} (1–5)
</Label>
<Input
id={`evaluation-${criterion.key}`}
type="number"
min={1}
max={5}
required
value={scores[criterion.key]}
onChange={(event) =>
setScores((prev) => ({ ...prev, [criterion.key]: event.target.value }))
}
/>
</div>
))}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AddEvaluationDialog queenId={queenId} />
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!evaluations || evaluations.length === 0) ? (
<QueryState
query={evaluationsQuery}
what="Bewertungen"
empty={
<EmptyState
icon={Star}
title="Noch keine Bewertung"
description="Erfasse die erste Bewertung für diese Königin."
/>
) : null}
{evaluations?.map((evaluation) => (
}
>
{(evaluations) =>
evaluations.map((evaluation) => (
<div key={evaluation.id} className="rounded-md border p-3">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">
@@ -327,7 +358,9 @@ export function QueenEvaluationsCard({ queenId }: { queenId: string }) {
{evaluation.broodPattern} · Honigertrag {evaluation.honeyYield}
</p>
</div>
))}
))
}
</QueryState>
</CardContent>
</Card>
);
@@ -4,6 +4,7 @@ import { History } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchQueenHistory } from "@/lib/queens/api";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
import {
Card,
CardContent,
@@ -12,36 +13,39 @@ import {
} from "@/components/ui/card";
export function QueenHistoryCard({ queenId }: { queenId: string }) {
const { data: history, isLoading } = useQuery({
const historyQuery = useQuery({
queryKey: ["queens", queenId, "history"],
queryFn: () => fetchQueenHistory(queenId),
});
if (isLoading || !history) {
return <p className="text-sm text-muted-foreground">Historie lädt…</p>;
}
return (
<Card>
<CardHeader>
<CardTitle>Volk-Historie</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{history.length === 0 ? (
<QueryState
query={historyQuery}
what="Einträge der Historie"
empty={
<EmptyState
icon={History}
title="Noch keine Historie"
description="Volkszuordnungen erscheinen hier."
/>
) : null}
{history.map((entry) => (
}
>
{(history) =>
history.map((entry) => (
<p key={entry.id}>
Seit {new Date(entry.startDate).toLocaleDateString("de-DE")}
{entry.endDate
? ` bis ${new Date(entry.endDate).toLocaleDateString("de-DE")}`
: " (aktuell)"}
</p>
))}
))
}
</QueryState>
</CardContent>
</Card>
);
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { fetchQueenLineage } from "@/lib/queens/api";
import { QueenLineageDescendant } from "@/lib/queens/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ErrorState, LoadingState } from "@/components/query-state";
function DescendantTree({ nodes }: { nodes: QueenLineageDescendant[] }) {
if (nodes.length === 0) {
@@ -33,10 +34,11 @@ function DescendantTree({ nodes }: { nodes: QueenLineageDescendant[] }) {
}
export function QueenLineageCard({ queenId }: { queenId: string }) {
const { data: lineage, isLoading } = useQuery({
const lineageQuery = useQuery({
queryKey: ["queens", queenId, "lineage"],
queryFn: () => fetchQueenLineage(queenId),
});
const { data: lineage, isLoading, isError } = lineageQuery;
const ancestors: { id: string; code: string; hatchYear: number | null }[] = [];
let current = lineage?.queen.mother ?? null;
@@ -56,8 +58,15 @@ export function QueenLineageCard({ queenId }: { queenId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && !hasLineage ? (
{isLoading ? <LoadingState what="Abstammungsdaten" /> : null}
{isError ? (
<ErrorState
what="Abstammungsdaten"
onRetry={() => void lineageQuery.refetch()}
isRetrying={lineageQuery.isFetching}
/>
) : null}
{!isLoading && !isError && !hasLineage ? (
<p className="text-muted-foreground">
Keine Abstammungsdaten hinterlegt. Trage bei einer Tochterkönigin
diese Königin als Mutter ein, um den Stammbaum aufzubauen.
@@ -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>
);
}