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