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