feat(ui): detail pages for colonies, apiaries and queens with one clear main action
New DetailHeader: heading, back link, status as icon + word, ONE primary action and a "Mehr" menu for exports, QR and the rest. - Colony: "Durchsicht erfassen" is the main action; sub-cards show a load error with retry instead of "Noch keine …". - Apiary: error states, weather with units. - Queen: edit card in sections, evaluations as 1-5 tap scales. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
2bc0db6f33
commit
031e47eaad
20 files changed
+1300
-794
No files matched your search
@@ -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
|
|
||||||
id="apiary-name"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="apiary-description">Beschreibung</Label>
|
|
||||||
<Textarea
|
|
||||||
id="apiary-description"
|
|
||||||
value={description}
|
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="apiary-location">Standort</Label>
|
|
||||||
<Input
|
|
||||||
id="apiary-location"
|
|
||||||
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>
|
|
||||||
<Input
|
<Input
|
||||||
id="apiary-latitude"
|
id={id}
|
||||||
type="number"
|
required
|
||||||
step="0.000001"
|
className="h-11"
|
||||||
inputMode="decimal"
|
aria-describedby={describedBy}
|
||||||
placeholder="z. B. 52.5200"
|
aria-invalid={invalid}
|
||||||
value={latitude}
|
value={name}
|
||||||
onChange={(event) => setLatitude(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setName(event.target.value);
|
||||||
|
setNameError(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="apiary-longitude">Längengrad</Label>
|
<Field label="Beschreibung">
|
||||||
|
{({ id }) => (
|
||||||
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Standort">
|
||||||
|
{({ id }) => (
|
||||||
<Input
|
<Input
|
||||||
id="apiary-longitude"
|
id={id}
|
||||||
type="number"
|
className="h-11"
|
||||||
step="0.000001"
|
value={location}
|
||||||
inputMode="decimal"
|
onChange={(event) => setLocation(event.target.value)}
|
||||||
placeholder="z. B. 13.4050"
|
|
||||||
value={longitude}
|
|
||||||
onChange={(event) => setLongitude(event.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</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={id}
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-11"
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
value={latitude}
|
||||||
|
onChange={(event) => setLatitude(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Längengrad" hint="z. B. 13.4050">
|
||||||
|
{({ id, describedBy }) => (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-11"
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
value={longitude}
|
||||||
|
onChange={(event) => setLongitude(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<Field label="Adresse">
|
||||||
<Label htmlFor="apiary-address">Adresse</Label>
|
{({ id }) => (
|
||||||
<Input
|
<Input
|
||||||
id="apiary-address"
|
id={id}
|
||||||
value={address}
|
className="h-11"
|
||||||
onChange={(event) => setAddress(event.target.value)}
|
value={address}
|
||||||
/>
|
onChange={(event) => setAddress(event.target.value)}
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
)}
|
||||||
<Label htmlFor="apiary-notes">Notizen</Label>
|
</Field>
|
||||||
<Textarea
|
<Field label="Notizen">
|
||||||
id="apiary-notes"
|
{({ id }) => (
|
||||||
value={notes}
|
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
)}
|
||||||
/>
|
</Field>
|
||||||
</div>
|
{submitError ? (
|
||||||
<div className="flex justify-between gap-2">
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
<Button type="submit" disabled={updateMutation.isPending}>
|
{submitError}
|
||||||
{updateMutation.isPending ? "Speichert…" : "Speichern"}
|
</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>
|
</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}
|
||||||
|
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}`} />}
|
||||||
|
>
|
||||||
|
<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="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div>
|
||||||
<Button
|
<QrCodeDialog entityType="apiaries" id={apiary.id} label="QR-Code für den Stand" />
|
||||||
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}`}
|
|
||||||
</Button>
|
|
||||||
</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,29 +43,35 @@ 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}
|
||||||
<EmptyState
|
what="Fütterungen"
|
||||||
icon={Utensils}
|
empty={
|
||||||
title="Noch keine Fütterung"
|
<EmptyState
|
||||||
description="Erfasse die erste Fütterung für dieses Volk."
|
icon={Utensils}
|
||||||
/>
|
title="Noch keine Fütterung"
|
||||||
) : null}
|
description="Erfasse die erste Fütterung für dieses Volk."
|
||||||
{feedings?.map((feeding) => (
|
/>
|
||||||
<div key={feeding.id} className="rounded-md border p-3">
|
}
|
||||||
<div className="flex items-center justify-between">
|
>
|
||||||
<p className="font-medium">
|
{(feedings) =>
|
||||||
{new Date(feeding.date).toLocaleDateString("de-DE")} ·{" "}
|
feedings.map((feeding) => (
|
||||||
{FEED_TYPE_LABELS[feeding.feedType]}
|
<div key={feeding.id} className="rounded-md border p-3">
|
||||||
</p>
|
<div className="flex items-center justify-between">
|
||||||
<FeedingEditDialog feeding={feeding} />
|
<p className="font-medium">
|
||||||
</div>
|
{new Date(feeding.date).toLocaleDateString("de-DE")} ·{" "}
|
||||||
<p className="text-muted-foreground">
|
{FEED_TYPE_LABELS[feeding.feedType]}
|
||||||
{feeding.amount} {feeding.unit}
|
</p>
|
||||||
{feeding.product ? ` · ${feeding.product}` : ""}
|
<FeedingEditDialog feeding={feeding} />
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<p className="text-muted-foreground">
|
||||||
))}
|
{feeding.amount} {feeding.unit}
|
||||||
|
{feeding.product ? ` · ${feeding.product}` : ""}
|
||||||
|
</p>
|
||||||
|
</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,52 +14,61 @@ 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">
|
||||||
<EmptyState
|
{(history) => <HistoryEntries history={history} />}
|
||||||
icon={History}
|
</QueryState>
|
||||||
title="Noch keine Historie"
|
|
||||||
description="Standortwechsel und Ableger erscheinen hier."
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{history.apiaryHistory.map((entry) => (
|
|
||||||
<p key={entry.id}>
|
|
||||||
Standortwechsel am {new Date(entry.movedAt).toLocaleDateString("de-DE")}
|
|
||||||
{entry.notes ? ` – ${entry.notes}` : ""}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
{history.lineageAsResult.map((entry) => (
|
|
||||||
<p key={entry.id}>
|
|
||||||
{entry.type === "SPLIT" ? "Als Ableger entstanden" : "Anderes Volk eingegliedert"} am{" "}
|
|
||||||
{new Date(entry.date).toLocaleDateString("de-DE")}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
{history.lineageAsSource.map((entry) => (
|
|
||||||
<p key={entry.id}>
|
|
||||||
{entry.type === "SPLIT" ? "Ableger gebildet" : "Vereinigt in anderes Volk"} am{" "}
|
|
||||||
{new Date(entry.date).toLocaleDateString("de-DE")}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{history.apiaryHistory.map((entry) => (
|
||||||
|
<p key={entry.id}>
|
||||||
|
Standortwechsel am {new Date(entry.movedAt).toLocaleDateString("de-DE")}
|
||||||
|
{entry.notes ? ` – ${entry.notes}` : ""}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{history.lineageAsResult.map((entry) => (
|
||||||
|
<p key={entry.id}>
|
||||||
|
{entry.type === "SPLIT" ? "Als Ableger entstanden" : "Anderes Volk eingegliedert"} am{" "}
|
||||||
|
{new Date(entry.date).toLocaleDateString("de-DE")}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{history.lineageAsSource.map((entry) => (
|
||||||
|
<p key={entry.id}>
|
||||||
|
{entry.type === "SPLIT" ? "Ableger gebildet" : "Vereinigt in anderes Volk"} am{" "}
|
||||||
|
{new Date(entry.date).toLocaleDateString("de-DE")}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,35 +30,41 @@ 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}
|
||||||
<EmptyState
|
what="Durchsichten"
|
||||||
icon={ClipboardCheck}
|
empty={
|
||||||
title="Noch keine Durchsicht"
|
<EmptyState
|
||||||
description="Dokumentiere die erste Durchsicht für dieses Volk."
|
icon={ClipboardCheck}
|
||||||
/>
|
title="Noch keine Durchsicht"
|
||||||
) : null}
|
description="Dokumentiere die erste Durchsicht für dieses Volk."
|
||||||
{inspections?.map((inspection) => (
|
/>
|
||||||
<div key={inspection.id} className="rounded-md border p-3">
|
}
|
||||||
<div className="flex items-center justify-between">
|
>
|
||||||
<p className="font-medium">
|
{(inspections) =>
|
||||||
{new Date(inspection.date).toLocaleDateString("de-DE")}
|
inspections.map((inspection) => (
|
||||||
</p>
|
<div key={inspection.id} className="rounded-md border p-3">
|
||||||
<InspectionEditDialog inspection={inspection} />
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<p className="font-medium">
|
||||||
<p className="text-muted-foreground">
|
{new Date(inspection.date).toLocaleDateString("de-DE")}
|
||||||
{inspection.colonyStrength !== null
|
</p>
|
||||||
? `${inspection.colonyStrength} Waben`
|
<InspectionEditDialog inspection={inspection} />
|
||||||
: "Stärke unbekannt"}
|
</div>
|
||||||
{inspection.queenSeen !== null
|
<p className="text-muted-foreground">
|
||||||
? ` · Königin ${inspection.queenSeen ? "gesehen" : "nicht gesehen"}`
|
{inspection.colonyStrength !== null
|
||||||
: ""}
|
? `${inspection.colonyStrength} Waben`
|
||||||
</p>
|
: "Stärke unbekannt"}
|
||||||
{inspection.healthIssues ? <p>Gesundheit: {inspection.healthIssues}</p> : null}
|
{inspection.queenSeen !== null
|
||||||
{inspection.varroaIssues ? <p>Varroa: {inspection.varroaIssues}</p> : null}
|
? ` · Königin ${inspection.queenSeen ? "gesehen" : "nicht gesehen"}`
|
||||||
{inspection.measures ? <p>Maßnahmen: {inspection.measures}</p> : null}
|
: ""}
|
||||||
</div>
|
</p>
|
||||||
))}
|
{inspection.healthIssues ? <p>Gesundheit: {inspection.healthIssues}</p> : null}
|
||||||
|
{inspection.varroaIssues ? <p>Varroa: {inspection.varroaIssues}</p> : null}
|
||||||
|
{inspection.measures ? <p>Maßnahmen: {inspection.measures}</p> : null}
|
||||||
|
</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,40 +32,48 @@ 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}
|
||||||
<EmptyState
|
what="Schwarmkontrollen"
|
||||||
icon={Sprout}
|
empty={
|
||||||
title="Noch keine Schwarmkontrolle"
|
<EmptyState
|
||||||
description="Erfasse die erste Weiselzellenkontrolle für dieses Volk."
|
icon={Sprout}
|
||||||
/>
|
title="Noch keine Schwarmkontrolle"
|
||||||
) : null}
|
description="Erfasse die erste Weiselzellenkontrolle für dieses Volk."
|
||||||
{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>
|
{(checks) =>
|
||||||
<SwarmControlEditDialog check={check} />
|
checks.map((check) => (
|
||||||
</div>
|
<div key={check.id} className="rounded-md border p-3">
|
||||||
<p className="text-muted-foreground">
|
<div className="flex items-center justify-between">
|
||||||
{check.queenCellsFound !== null
|
<p className="font-medium">{new Date(check.date).toLocaleDateString("de-DE")}</p>
|
||||||
? `${check.queenCellsFound} Weiselzelle(n) gefunden`
|
<SwarmControlEditDialog check={check} />
|
||||||
: "Keine Weiselzellen erfasst"}
|
</div>
|
||||||
{check.queenCellsCapped ? (
|
<p className="text-muted-foreground">
|
||||||
<Badge variant="destructive" className="ml-2">
|
{check.queenCellsFound !== null
|
||||||
Verdeckelt
|
? `${check.queenCellsFound} Weiselzelle(n) gefunden`
|
||||||
</Badge>
|
: "Keine Weiselzellen erfasst"}
|
||||||
) : null}
|
{check.queenCellsCapped ? (
|
||||||
</p>
|
<Badge variant="destructive" className="ml-2">
|
||||||
<p>Schwarmstimmung: {SWARM_MOOD_LABELS[check.swarmMood]}</p>
|
Verdeckelt
|
||||||
{check.actionTaken !== "NONE" ? (
|
</Badge>
|
||||||
<p>Maßnahme: {SWARM_ACTION_LABELS[check.actionTaken]}</p>
|
) : null}
|
||||||
) : null}
|
</p>
|
||||||
{check.nextCheckDate ? (
|
<p>Schwarmstimmung: {SWARM_MOOD_LABELS[check.swarmMood]}</p>
|
||||||
<p>Nächste Kontrolle: {new Date(check.nextCheckDate).toLocaleDateString("de-DE")}</p>
|
{check.actionTaken !== "NONE" ? (
|
||||||
) : null}
|
<p>Maßnahme: {SWARM_ACTION_LABELS[check.actionTaken]}</p>
|
||||||
{check.notes ? <p>{check.notes}</p> : null}
|
) : null}
|
||||||
</div>
|
{check.nextCheckDate ? (
|
||||||
))}
|
<p>
|
||||||
|
Nächste Kontrolle: {new Date(check.nextCheckDate).toLocaleDateString("de-DE")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{check.notes ? <p>{check.notes}</p> : null}
|
||||||
|
</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,32 +30,38 @@ 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}
|
||||||
<EmptyState
|
what="Behandlungen"
|
||||||
icon={Syringe}
|
empty={
|
||||||
title="Noch keine Behandlung"
|
<EmptyState
|
||||||
description="Dokumentiere die erste Behandlung für dieses Volk."
|
icon={Syringe}
|
||||||
/>
|
title="Noch keine Behandlung"
|
||||||
) : null}
|
description="Dokumentiere die erste Behandlung für dieses Volk."
|
||||||
{treatments?.map((treatment) => (
|
/>
|
||||||
<div key={treatment.id} className="rounded-md border p-3">
|
}
|
||||||
<div className="flex items-center justify-between">
|
>
|
||||||
<p className="font-medium">
|
{(treatments) =>
|
||||||
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}
|
treatments.map((treatment) => (
|
||||||
</p>
|
<div key={treatment.id} className="rounded-md border p-3">
|
||||||
<TreatmentEditDialog treatment={treatment} />
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<p className="font-medium">
|
||||||
<p className="text-muted-foreground">
|
{new Date(treatment.date).toLocaleDateString("de-DE")} · {treatment.product}
|
||||||
{treatment.amount ?? "—"}
|
</p>
|
||||||
{treatment.method ? ` · ${treatment.method}` : ""}
|
<TreatmentEditDialog treatment={treatment} />
|
||||||
</p>
|
</div>
|
||||||
{treatment.reason ? <p>Grund: {treatment.reason}</p> : null}
|
<p className="text-muted-foreground">
|
||||||
{treatment.withdrawalPeriodDays !== null ? (
|
{treatment.amount ?? "—"}
|
||||||
<p>Wartezeit: {treatment.withdrawalPeriodDays} Tag(e)</p>
|
{treatment.method ? ` · ${treatment.method}` : ""}
|
||||||
) : null}
|
</p>
|
||||||
</div>
|
{treatment.reason ? <p>Grund: {treatment.reason}</p> : null}
|
||||||
))}
|
{treatment.withdrawalPeriodDays !== null ? (
|
||||||
|
<p>Wartezeit: {treatment.withdrawalPeriodDays} Tag(e)</p>
|
||||||
|
) : null}
|
||||||
|
</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,33 +64,45 @@ 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}
|
||||||
<EmptyState
|
what="Varroamessungen"
|
||||||
icon={Bug}
|
empty={
|
||||||
title="Noch keine Varroamessung"
|
<EmptyState
|
||||||
description="Erfasse die erste Messung für dieses Volk."
|
icon={Bug}
|
||||||
/>
|
title="Noch keine Varroamessung"
|
||||||
) : null}
|
description="Erfasse die erste Messung für dieses Volk."
|
||||||
<VarroaTrendChart values={values} />
|
/>
|
||||||
{measurements?.map((measurement) => (
|
}
|
||||||
<div key={measurement.id} className="rounded-md border p-3">
|
>
|
||||||
<div className="flex items-center justify-between">
|
{(measurements) => (
|
||||||
<p className="font-medium">
|
<>
|
||||||
{new Date(measurement.date).toLocaleDateString("de-DE")} ·{" "}
|
<VarroaTrendChart
|
||||||
{VARROA_METHOD_LABELS[measurement.method]}
|
values={measurements
|
||||||
</p>
|
.map((measurement) => measurement.miteCount ?? measurement.calculatedValue)
|
||||||
<VarroaEditDialog measurement={measurement} />
|
.filter((value): value is number => value !== null)}
|
||||||
</div>
|
/>
|
||||||
<p className="text-muted-foreground">
|
{measurements.map((measurement) => (
|
||||||
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
|
<div key={measurement.id} className="rounded-md border p-3">
|
||||||
{measurement.calculatedValue !== null
|
<div className="flex items-center justify-between">
|
||||||
? ` · ${measurement.calculatedValue.toFixed(1)} Milben/Tag`
|
<p className="font-medium">
|
||||||
: ""}
|
{new Date(measurement.date).toLocaleDateString("de-DE")} ·{" "}
|
||||||
</p>
|
{VARROA_METHOD_LABELS[measurement.method]}
|
||||||
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
|
</p>
|
||||||
</div>
|
<VarroaEditDialog measurement={measurement} />
|
||||||
))}
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
|
||||||
|
{measurement.calculatedValue !== null
|
||||||
|
? ` · ${measurement.calculatedValue.toFixed(1)} Milben/Tag`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
|
||||||
|
</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
|
||||||
<Button
|
icon={Layers}
|
||||||
variant="outline"
|
title={colony.code}
|
||||||
disabled={recordCardMutation.isPending}
|
subtitle={COLONY_TYPE_LABELS[colony.colonyType]}
|
||||||
onClick={() => recordCardMutation.mutate()}
|
status={<DetailStatus status={colony.status} label={COLONY_STATUS_LABELS[colony.status]} />}
|
||||||
>
|
primaryAction={
|
||||||
{recordCardMutation.isPending ? "Wird erstellt…" : "Stockkarte (PDF)"}
|
<Button
|
||||||
</Button>
|
size="touch"
|
||||||
<Button
|
nativeButton={false}
|
||||||
variant="outline"
|
render={<Link href={`/inspections/new?colonyId=${colony.id}`} />}
|
||||||
disabled={exportReportMutation.isPending}
|
>
|
||||||
onClick={() => exportReportMutation.mutate()}
|
<ClipboardCheck aria-hidden />
|
||||||
>
|
Durchsicht erfassen
|
||||||
{exportReportMutation.isPending
|
</Button>
|
||||||
? "Wird erstellt…"
|
}
|
||||||
: `Jahresbericht ${CURRENT_YEAR}`}
|
moreActions={[
|
||||||
</Button>
|
{
|
||||||
<QrCodeDialog entityType="colonies" id={colony.id} />
|
label: recordCardMutation.isPending ? "Stockkarte wird erstellt…" : "Stockkarte (PDF)",
|
||||||
</div>
|
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="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,171 +80,221 @@ 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>
|
||||||
<Input
|
{({ id }) => (
|
||||||
id="queen-hatch-year"
|
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
|
||||||
type="number"
|
<SelectTrigger id={id} className="h-11 w-full">
|
||||||
inputMode="numeric"
|
<SelectValue>{QUEEN_STATUS_LABELS[status]}</SelectValue>
|
||||||
value={hatchYear}
|
</SelectTrigger>
|
||||||
onChange={(event) => handleHatchYearChange(event.target.value)}
|
<SelectContent>
|
||||||
/>
|
{QUEEN_STATUSES.map((option) => (
|
||||||
</div>
|
<SelectItem key={option} value={option}>
|
||||||
<div className="flex flex-col gap-2">
|
{QUEEN_STATUS_LABELS[option]}
|
||||||
<Label htmlFor="queen-breed">Zucht/Rasse</Label>
|
</SelectItem>
|
||||||
<Input
|
))}
|
||||||
id="queen-breed"
|
</SelectContent>
|
||||||
value={breed}
|
</Select>
|
||||||
onChange={(event) => setBreed(event.target.value)}
|
)}
|
||||||
/>
|
</Field>
|
||||||
</div>
|
<Field label="Schlupfjahr" hint="Die Zeichenfarbe wird passend vorgeschlagen.">
|
||||||
<div className="flex flex-col gap-2">
|
{({ id, describedBy }) => (
|
||||||
<Label htmlFor="queen-origin">Herkunft</Label>
|
<Input
|
||||||
<Input
|
id={id}
|
||||||
id="queen-origin"
|
type="number"
|
||||||
value={origin}
|
inputMode="numeric"
|
||||||
onChange={(event) => setOrigin(event.target.value)}
|
className="h-11"
|
||||||
/>
|
aria-describedby={describedBy}
|
||||||
</div>
|
value={hatchYear}
|
||||||
<div className="flex flex-col gap-2">
|
onChange={(event) => handleHatchYearChange(event.target.value)}
|
||||||
<Label htmlFor="queen-breeder">Züchter</Label>
|
/>
|
||||||
<Input
|
)}
|
||||||
id="queen-breeder"
|
</Field>
|
||||||
value={breeder}
|
<div className="grid grid-cols-2 gap-3">
|
||||||
onChange={(event) => setBreeder(event.target.value)}
|
<Field label="Zeichenfarbe">
|
||||||
/>
|
{({ id }) => (
|
||||||
</div>
|
<Input
|
||||||
<div className="flex flex-col gap-2">
|
id={id}
|
||||||
<Label htmlFor="queen-marking-color">Zeichenfarbe</Label>
|
className="h-11"
|
||||||
<Input
|
value={markingColor}
|
||||||
id="queen-marking-color"
|
onChange={(event) => setMarkingColor(event.target.value)}
|
||||||
value={markingColor}
|
/>
|
||||||
onChange={(event) => setMarkingColor(event.target.value)}
|
)}
|
||||||
/>
|
</Field>
|
||||||
</div>
|
<Field label="Zeichennummer">
|
||||||
<div className="flex flex-col gap-2">
|
{({ id }) => (
|
||||||
<Label htmlFor="queen-marking-number">Zeichennummer</Label>
|
<Input
|
||||||
<Input
|
id={id}
|
||||||
id="queen-marking-number"
|
className="h-11"
|
||||||
value={markingNumber}
|
value={markingNumber}
|
||||||
onChange={(event) => setMarkingNumber(event.target.value)}
|
onChange={(event) => setMarkingNumber(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="queen-mating-type">Begattungsart</Label>
|
</div>
|
||||||
<Input
|
</FormSection>
|
||||||
id="queen-mating-type"
|
|
||||||
value={matingType}
|
<FormSection
|
||||||
onChange={(event) => setMatingType(event.target.value)}
|
step={2}
|
||||||
/>
|
title="Herkunft & Zucht"
|
||||||
</div>
|
collapsible
|
||||||
<div className="flex flex-col gap-2">
|
defaultOpen={hasOrigin}
|
||||||
<Label htmlFor="queen-mating-status">Begattungsstatus</Label>
|
fieldCount={6}
|
||||||
<Input
|
>
|
||||||
id="queen-mating-status"
|
<Field label="Zucht/Rasse">
|
||||||
value={matingStatus}
|
{({ id }) => (
|
||||||
onChange={(event) => setMatingStatus(event.target.value)}
|
<Input
|
||||||
/>
|
id={id}
|
||||||
</div>
|
className="h-11"
|
||||||
<div className="flex flex-col gap-2">
|
value={breed}
|
||||||
<Label htmlFor="queen-insemination-data">Besamungsdaten</Label>
|
onChange={(event) => setBreed(event.target.value)}
|
||||||
<Input
|
/>
|
||||||
id="queen-insemination-data"
|
)}
|
||||||
value={inseminationData}
|
</Field>
|
||||||
onChange={(event) => setInseminationData(event.target.value)}
|
<Field label="Herkunft">
|
||||||
/>
|
{({ id }) => (
|
||||||
</div>
|
<Input
|
||||||
<div className="flex flex-col gap-2">
|
id={id}
|
||||||
<Label htmlFor="queen-mother-line">Mutterlinie</Label>
|
className="h-11"
|
||||||
<Input
|
value={origin}
|
||||||
id="queen-mother-line"
|
onChange={(event) => setOrigin(event.target.value)}
|
||||||
value={motherLine}
|
/>
|
||||||
onChange={(event) => setMotherLine(event.target.value)}
|
)}
|
||||||
/>
|
</Field>
|
||||||
</div>
|
<Field label="Züchter">
|
||||||
<div className="flex flex-col gap-2">
|
{({ id }) => (
|
||||||
<Label htmlFor="queen-father-line">Vaterlinie</Label>
|
<Input
|
||||||
<Input
|
id={id}
|
||||||
id="queen-father-line"
|
className="h-11"
|
||||||
value={fatherLine}
|
value={breeder}
|
||||||
onChange={(event) => setFatherLine(event.target.value)}
|
onChange={(event) => setBreeder(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
</Field>
|
||||||
<Label htmlFor="queen-mother">Mutter</Label>
|
<Field label="Mutter">
|
||||||
<Select
|
{({ id }) => (
|
||||||
value={motherQueenId || "NONE"}
|
<Select
|
||||||
onValueChange={(value) =>
|
value={motherQueenId || "NONE"}
|
||||||
setMotherQueenId(value === "NONE" ? "" : (value ?? ""))
|
onValueChange={(value) =>
|
||||||
}
|
setMotherQueenId(value === "NONE" ? "" : (value ?? ""))
|
||||||
>
|
}
|
||||||
<SelectTrigger id="queen-mother" className="w-full">
|
>
|
||||||
<SelectValue placeholder="Keine Mutter hinterlegt">
|
<SelectTrigger id={id} className="h-11 w-full">
|
||||||
{motherQueenId
|
<SelectValue placeholder="Keine Mutter hinterlegt">
|
||||||
? motherOptions.find((option) => option.id === motherQueenId)?.code
|
{motherQueenId
|
||||||
: "Keine Mutter hinterlegt"}
|
? motherOptions.find((option) => option.id === motherQueenId)?.code
|
||||||
</SelectValue>
|
: "Keine Mutter hinterlegt"}
|
||||||
</SelectTrigger>
|
</SelectValue>
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="NONE">Keine Mutter hinterlegt</SelectItem>
|
<SelectContent>
|
||||||
{motherOptions.map((option) => (
|
<SelectItem value="NONE">Keine Mutter hinterlegt</SelectItem>
|
||||||
<SelectItem key={option.id} value={option.id}>
|
{motherOptions.map((option) => (
|
||||||
{option.code}
|
<SelectItem key={option.id} value={option.id}>
|
||||||
</SelectItem>
|
{option.code}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
<div className="flex flex-col gap-2">
|
)}
|
||||||
<Label htmlFor="queen-status">Status</Label>
|
</Field>
|
||||||
<Select value={status} onValueChange={(value) => setStatus(value as QueenStatus)}>
|
<Field label="Mutterlinie">
|
||||||
<SelectTrigger id="queen-status">
|
{({ id }) => (
|
||||||
<SelectValue>{QUEEN_STATUS_LABELS[status]}</SelectValue>
|
<Input
|
||||||
</SelectTrigger>
|
id={id}
|
||||||
<SelectContent>
|
className="h-11"
|
||||||
{QUEEN_STATUSES.map((option) => (
|
value={motherLine}
|
||||||
<SelectItem key={option} value={option}>
|
onChange={(event) => setMotherLine(event.target.value)}
|
||||||
{QUEEN_STATUS_LABELS[option]}
|
/>
|
||||||
</SelectItem>
|
)}
|
||||||
))}
|
</Field>
|
||||||
</SelectContent>
|
<Field label="Vaterlinie">
|
||||||
</Select>
|
{({ id }) => (
|
||||||
</div>
|
<Input
|
||||||
<div className="flex flex-col gap-2">
|
id={id}
|
||||||
<Label htmlFor="queen-notes">Notizen</Label>
|
className="h-11"
|
||||||
<Textarea
|
value={fatherLine}
|
||||||
id="queen-notes"
|
onChange={(event) => setFatherLine(event.target.value)}
|
||||||
value={notes}
|
/>
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
)}
|
||||||
/>
|
</Field>
|
||||||
</div>
|
</FormSection>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
<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,109 +215,104 @@ 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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<EditTriggerButton />} />
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bewertung bearbeiten</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<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>
|
||||||
|
<SelectItem value={NO_COLONY}>Kein Volk</SelectItem>
|
||||||
|
{(colonies ?? []).map((colony) => (
|
||||||
|
<SelectItem key={colony.id} value={colony.id}>
|
||||||
|
{colony.code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
<Button variant="ghost" size="sm">
|
<Button size="touch" variant="outline">
|
||||||
<Pencil className="size-3.5" />
|
Neue Bewertung
|
||||||
Bearbeiten
|
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Bewertung bearbeiten</DialogTitle>
|
<DialogTitle>Königin bewerten</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>
|
<DialogSubmitBar isPending={mutation.isPending} error={error} />
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Kein Volk" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NO_COLONY}>Kein Volk</SelectItem>
|
|
||||||
{(colonies ?? []).map((colony) => (
|
|
||||||
<SelectItem key={colony.id} value={colony.id}>
|
|
||||||
{colony.code}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</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"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -207,127 +320,47 @@ 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}
|
||||||
<EmptyState
|
what="Bewertungen"
|
||||||
icon={Star}
|
empty={
|
||||||
title="Noch keine Bewertung"
|
<EmptyState
|
||||||
description="Erfasse die erste Bewertung für diese Königin."
|
icon={Star}
|
||||||
/>
|
title="Noch keine Bewertung"
|
||||||
) : null}
|
description="Erfasse die erste Bewertung für diese Königin."
|
||||||
{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">
|
{(evaluations) =>
|
||||||
{new Date(evaluation.date).toLocaleDateString("de-DE")}
|
evaluations.map((evaluation) => (
|
||||||
</p>
|
<div key={evaluation.id} className="rounded-md border p-3">
|
||||||
<EditEvaluationDialog evaluation={evaluation} queenId={queenId} />
|
<div className="flex items-start justify-between gap-2">
|
||||||
</div>
|
<p className="font-medium">
|
||||||
<p className="text-muted-foreground">
|
{new Date(evaluation.date).toLocaleDateString("de-DE")}
|
||||||
Stärke {evaluation.colonyStrength} · Sanftmut {evaluation.gentleness} · Wabensitz{" "}
|
</p>
|
||||||
{evaluation.combBuild} · Schwarmtrieb {evaluation.swarmingTendency} · Brutbild{" "}
|
<EditEvaluationDialog evaluation={evaluation} queenId={queenId} />
|
||||||
{evaluation.broodPattern} · Honigertrag {evaluation.honeyYield}
|
</div>
|
||||||
</p>
|
<p className="text-muted-foreground">
|
||||||
</div>
|
Stärke {evaluation.colonyStrength} · Sanftmut {evaluation.gentleness} · Wabensitz{" "}
|
||||||
))}
|
{evaluation.combBuild} · Schwarmtrieb {evaluation.swarmingTendency} · Brutbild{" "}
|
||||||
|
{evaluation.broodPattern} · Honigertrag {evaluation.honeyYield}
|
||||||
|
</p>
|
||||||
|
</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
|
||||||
<EmptyState
|
query={historyQuery}
|
||||||
icon={History}
|
what="Einträge der Historie"
|
||||||
title="Noch keine Historie"
|
empty={
|
||||||
description="Volkszuordnungen erscheinen hier."
|
<EmptyState
|
||||||
/>
|
icon={History}
|
||||||
) : null}
|
title="Noch keine Historie"
|
||||||
{history.map((entry) => (
|
description="Volkszuordnungen erscheinen hier."
|
||||||
<p key={entry.id}>
|
/>
|
||||||
Seit {new Date(entry.startDate).toLocaleDateString("de-DE")}
|
}
|
||||||
{entry.endDate
|
>
|
||||||
? ` bis ${new Date(entry.endDate).toLocaleDateString("de-DE")}`
|
{(history) =>
|
||||||
: " (aktuell)"}
|
history.map((entry) => (
|
||||||
</p>
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user