feat(ui): harvest, extraction, bottling and equipment pages with heading and way back

Same DetailHeader as colonies/queens: an h1, a 44 px back link, state as
icon + word, the obvious next step as the one primary button (harvest:
"Schleuderung erfassen"); a failed load offers a retry and a way back
instead of a bare red line. Colonies of a harvest are tappable links;
changing equipment condition confirms with a toast.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 18:36:39 +02:00
1 parent d4b1addb7d
commit 86f9291ee3
4 files changed
+122 -123

No files matched your search

@@ -11,41 +11,36 @@ import { useAuth } from "@/lib/auth/auth-context";
import { BottlingEditDialog } from "./bottling-edit-dialog";
import { BottlingDeleteButton } from "./bottling-delete-button";
import { PrintLabelsCard } from "./print-labels-card";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
const BACK = { backHref: "/bottling", backLabel: "Alle Abfüllungen" };
export default function BottlingBatchDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const { data: bottlingBatch, isLoading, isError } = useQuery({
const bottlingQuery = useQuery({
queryKey: ["bottling-batches", params.id],
queryFn: () => fetchBottlingBatch(params.id),
});
const bottlingBatch = bottlingQuery.data;
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !bottlingBatch) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Abfüllung konnte nicht geladen werden.
</p>
);
if (bottlingQuery.isLoading || bottlingQuery.isError || !bottlingBatch) {
return <DetailQueryFallback query={bottlingQuery} what="Daten der Abfüllung" {...BACK} />;
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Package}
title={`Abfüllung ${bottlingBatch.lotNumber}`}
subtitle={`${bottlingBatch.jarCount} Gläser à ${bottlingBatch.jarSize} g`}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Package className="size-4" />
</span>
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
</div>
<div className="flex items-center gap-2">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Angaben</CardTitle>
<div className="flex flex-wrap items-center gap-2">
{hasPermission("bottling:update") ? (
<BottlingEditDialog bottlingBatch={bottlingBatch} />
) : null}
@@ -8,8 +8,6 @@ import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
import { EquipmentEditDialog } from "./equipment-edit-dialog";
import { EquipmentCondition } from "@/lib/equipment/types";
import { EQUIPMENT_CONDITIONS, EQUIPMENT_CONDITION_LABELS } from "@/lib/equipment/labels";
import { statusTone } from "@/lib/status-tone";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
@@ -18,55 +16,52 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { errorMessage } from "@/lib/forms/error-message";
const BACK = { backHref: "/equipment", backLabel: "Gesamte Ausrüstung" };
export default function EquipmentDetailPage() {
const params = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { data: equipment, isLoading, isError } = useQuery({
const equipmentQuery = useQuery({
queryKey: ["equipment", params.id],
queryFn: () => fetchEquipment(params.id),
});
const equipment = equipmentQuery.data;
const updateMutation = useMutation({
mutationFn: (condition: EquipmentCondition) =>
updateEquipment(params.id, { condition }),
onSuccess: () => {
onSuccess: (_, condition) => {
void queryClient.invalidateQueries({ queryKey: ["equipment", params.id] });
void queryClient.invalidateQueries({ queryKey: ["equipment"] });
toast.success(`Zustand: ${EQUIPMENT_CONDITION_LABELS[condition]}`);
},
onError: () => toast.error("Zustand konnte nicht gespeichert werden"),
onError: (error) => toast.error(errorMessage(error, "Zustand konnte nicht gespeichert werden")),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !equipment) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Gegenstand konnte nicht geladen werden.
</p>
);
if (equipmentQuery.isLoading || equipmentQuery.isError || !equipment) {
return <DetailQueryFallback query={equipmentQuery} what="Daten des Gegenstands" {...BACK} />;
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Wrench}
title={equipment.name}
status={
<DetailStatus
status={equipment.condition}
label={EQUIPMENT_CONDITION_LABELS[equipment.condition]}
/>
}
{...BACK}
/>
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-sky)" }}
>
<Wrench className="size-4" />
</span>
<CardTitle>{equipment.name}</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge variant={statusTone(equipment.condition)}>
{EQUIPMENT_CONDITION_LABELS[equipment.condition]}
</Badge>
<EquipmentEditDialog equipment={equipment} />
</div>
<CardTitle>Angaben</CardTitle>
<EquipmentEditDialog equipment={equipment} />
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p>
@@ -91,7 +86,7 @@ export default function EquipmentDetailPage() {
value ? updateMutation.mutate(value as EquipmentCondition) : undefined
}
>
<SelectTrigger className="w-40">
<SelectTrigger className="w-full sm:w-48" aria-label="Zustand ändern">
<SelectValue>{EQUIPMENT_CONDITION_LABELS[equipment.condition]}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -7,45 +7,40 @@ import { useQuery } from "@tanstack/react-query";
import { fetchExtraction } from "@/lib/extractions/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { ExtractionEditDialog } from "./extraction-edit-dialog";
const BACK = { backHref: "/extractions", backLabel: "Alle Schleuderungen" };
export default function ExtractionDetailPage() {
const params = useParams<{ id: string }>();
const {
data: extraction,
isLoading,
isError,
} = useQuery({
const extractionQuery = useQuery({
queryKey: ["extractions", params.id],
queryFn: () => fetchExtraction(params.id),
});
const extraction = extractionQuery.data;
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !extraction) {
if (extractionQuery.isLoading || extractionQuery.isError || !extraction) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Schleuderung konnte nicht geladen werden.
</p>
<DetailQueryFallback query={extractionQuery} what="Daten der Schleuderung" {...BACK} />
);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={FlaskConical}
title={`Schleuderung vom ${new Date(extraction.date).toLocaleDateString("de-DE")}`}
primaryAction={
<Button size="touch" nativeButton={false} render={<Link href="/honey-batches" />}>
Zu den Honigchargen
</Button>
}
{...BACK}
/>
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<FlaskConical className="size-4" />
</span>
<CardTitle>
Schleuderung vom {new Date(extraction.date).toLocaleDateString("de-DE")}
</CardTitle>
</div>
<CardTitle>Angaben</CardTitle>
<ExtractionEditDialog extraction={extraction} />
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
@@ -63,7 +58,10 @@ export default function ExtractionDetailPage() {
<ul className="list-disc pl-5">
{extraction.harvests.map((entry) => (
<li key={entry.id}>
<Link href={`/harvests/${entry.harvestId}`} className="hover:underline">
<Link
href={`/harvests/${entry.harvestId}`}
className="inline-flex min-h-11 items-center hover:underline"
>
{new Date(entry.harvest.date).toLocaleDateString("de-DE")} ·{" "}
{entry.harvest.colonies.map((c) => c.colony.code).join(", ")}
</Link>
@@ -71,14 +69,6 @@ export default function ExtractionDetailPage() {
))}
</ul>
</div>
<Button
className="w-fit"
variant="outline"
nativeButton={false}
render={<Link href="/honey-batches" />}
>
Zu den Honigchargen
</Button>
</CardContent>
</Card>
</main>
@@ -7,63 +7,82 @@ import { useQuery } from "@tanstack/react-query";
import { fetchHarvest } from "@/lib/harvests/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
import { HarvestEditDialog } from "./harvest-edit-dialog";
const BACK = { backHref: "/harvests", backLabel: "Alle Ernten" };
export default function HarvestDetailPage() {
const params = useParams<{ id: string }>();
const { data: harvest, isLoading, isError } = useQuery({
const harvestQuery = useQuery({
queryKey: ["harvests", params.id],
queryFn: () => fetchHarvest(params.id),
});
const harvest = harvestQuery.data;
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !harvest) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Ernte konnte nicht geladen werden.
</p>
);
if (harvestQuery.isLoading || harvestQuery.isError || !harvest) {
return <DetailQueryFallback query={harvestQuery} what="Daten der Ernte" {...BACK} />;
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Boxes className="size-4" />
</span>
<CardTitle>Ernte vom {new Date(harvest.date).toLocaleDateString("de-DE")}</CardTitle>
</div>
<HarvestEditDialog harvest={harvest} />
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{harvest.honeyType ? <p>Honigsorte: {harvest.honeyType}</p> : null}
{harvest.netWeight !== null ? <p>Nettogewicht: {harvest.netWeight} kg</p> : null}
{harvest.honeySuperCount !== null ? (
<p>Honigräume: {harvest.honeySuperCount}</p>
) : null}
{harvest.notes ? <p>Notizen: {harvest.notes}</p> : null}
<div>
<p className="font-medium">Beteiligte Völker</p>
<ul className="list-disc pl-5">
{harvest.colonies.map((entry) => (
<li key={entry.id}>{entry.colony.code}</li>
))}
</ul>
</div>
<DetailHeader
icon={Boxes}
title={`Ernte vom ${new Date(harvest.date).toLocaleDateString("de-DE")}`}
subtitle={harvest.honeyType ?? undefined}
primaryAction={
<Button
className="w-fit"
size="touch"
nativeButton={false}
render={<Link href={`/extractions/new?harvestId=${harvest.id}`} />}
>
Zur Schleuderung
Schleuderung erfassen
</Button>
}
{...BACK}
/>
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
<CardTitle>Angaben</CardTitle>
<HarvestEditDialog harvest={harvest} />
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
{harvest.honeyType ? (
<>
<dt className="text-muted-foreground">Honigsorte</dt>
<dd>{harvest.honeyType}</dd>
</>
) : null}
{harvest.netWeight !== null ? (
<>
<dt className="text-muted-foreground">Nettogewicht</dt>
<dd>{harvest.netWeight} kg</dd>
</>
) : null}
{harvest.honeySuperCount !== null ? (
<>
<dt className="text-muted-foreground">Honigräume</dt>
<dd>{harvest.honeySuperCount}</dd>
</>
) : null}
</dl>
{harvest.notes ? <p>Notizen: {harvest.notes}</p> : null}
<div>
<p className="font-medium">Beteiligte Völker</p>
<ul className="flex flex-wrap gap-2 pt-1">
{harvest.colonies.map((entry) => (
<li key={entry.id}>
<Link
href={`/colonies/${entry.colony.id}`}
className="inline-flex min-h-11 items-center rounded-lg border px-3 hover:bg-muted"
>
{entry.colony.code}
</Link>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
</main>