feat(ui): honey batch page with one main action, traceability errors, confirmed removal

- DetailHeader: "Abfüllung anlegen" as the main action, PDF export in
  "Mehr", state as icon + word, retry and way back on load errors.
- Traceability says what it shows and offers a retry on errors instead
  of "Lädt…" forever.
- Removing a container allocation asks first and says what changes.

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:37:37 +02:00
1 parent 86f9291ee3
commit c3dc596170
2 files changed
+69 -56

No files matched your search

@@ -1,7 +1,6 @@
"use client";
import { FormEvent, useState } from "react";
import { Trash2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -15,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { EmptyState } from "@/components/empty-state";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Boxes } from "lucide-react";
import {
Select,
@@ -132,16 +132,16 @@ export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch:
</p>
<p className="text-muted-foreground">{allocation.amount} kg</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
disabled={removeMutation.isPending}
onClick={() => removeMutation.mutate(allocation.storageContainerId)}
aria-label="Zuweisung entfernen"
>
<Trash2 className="size-4" />
</Button>
<ConfirmDialog
triggerLabel="Entfernen"
triggerVariant="ghost"
triggerDisabled={removeMutation.isPending}
title="Zuweisung entfernen?"
description={`${allocation.amount} kg dieser Charge gelten danach nicht mehr als in Behälter ${allocation.storageContainer.code} gelagert. Der Honig selbst bleibt in der Charge.`}
confirmLabel="Entfernen"
isPending={removeMutation.isPending}
onConfirm={() => removeMutation.mutate(allocation.storageContainerId)}
/>
</div>
))}
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { Droplet, FlaskConical } from "lucide-react";
import { Droplet, FileText, FlaskConical } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
@@ -11,9 +11,11 @@ import { exportHoneyBatchTraceabilityPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { EmptyState } from "@/components/empty-state";
import { ErrorState, LoadingState } from "@/components/query-state";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { errorMessage } from "@/lib/forms/error-message";
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
import { HoneyBatchContainerAllocationsCard } from "./honey-batch-container-allocations-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AddMeasurementDialog } from "./add-measurement-dialog";
@@ -21,50 +23,68 @@ import { EditMeasurementDialog } from "./edit-measurement-dialog";
import { ExtractionEditDialog } from "./extraction-edit-dialog";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
const BACK = { backHref: "/honey-batches", backLabel: "Alle Honigchargen" };
export default function HoneyBatchDetailPage() {
const params = useParams<{ id: string }>();
const { data: honeyBatch, isLoading, isError } = useQuery({
const honeyBatchQuery = useQuery({
queryKey: ["honey-batches", params.id],
queryFn: () => fetchHoneyBatch(params.id),
});
const { data: traceability } = useQuery({
const honeyBatch = honeyBatchQuery.data;
const traceabilityQuery = useQuery({
queryKey: ["honey-batches", params.id, "traceability"],
queryFn: () => fetchHoneyBatchTraceability(params.id),
});
const traceability = traceabilityQuery.data;
const exportPdfMutation = useMutation({
mutationFn: () => exportHoneyBatchTraceabilityPdf(params.id),
onError: () => toast.error("PDF-Bericht konnte nicht exportiert werden"),
onError: (error) =>
toast.error(errorMessage(error, "PDF-Bericht konnte nicht exportiert werden")),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
if (isError || !honeyBatch) {
return (
<p role="alert" className="p-4 text-sm text-destructive sm:p-6">
Honigcharge konnte nicht geladen werden.
</p>
);
if (honeyBatchQuery.isLoading || honeyBatchQuery.isError || !honeyBatch) {
return <DetailQueryFallback query={honeyBatchQuery} what="Daten der Honigcharge" {...BACK} />;
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader
icon={Droplet}
title={honeyBatch.batchNumber}
subtitle={honeyBatch.honeyType ?? undefined}
status={
<DetailStatus
status={honeyBatch.status}
label={HONEY_BATCH_STATUS_LABELS[honeyBatch.status]}
/>
}
primaryAction={
<Button
size="touch"
nativeButton={false}
render={<Link href={`/bottling/new?honeyBatchId=${honeyBatch.id}`} />}
>
Abfüllung anlegen
</Button>
}
moreActions={[
{
label: "Rückverfolgung als PDF",
icon: FileText,
onSelect: () => exportPdfMutation.mutate(),
disabled: exportPdfMutation.isPending,
},
]}
{...BACK}
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Droplet className="size-4" />
</span>
<CardTitle className="font-mono">{honeyBatch.batchNumber}</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge>{HONEY_BATCH_STATUS_LABELS[honeyBatch.status]}</Badge>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<CardTitle>Angaben</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<HoneyBatchEditDialog honeyBatch={honeyBatch} />
<QrCodeDialog entityType="honey-batches" id={honeyBatch.id} />
</div>
@@ -75,15 +95,6 @@ export default function HoneyBatchDetailPage() {
{honeyBatch.waterContent !== null ? (
<p>Wassergehalt: {honeyBatch.waterContent}%</p>
) : null}
<Button
variant="outline"
size="sm"
className="w-fit"
nativeButton={false}
render={<Link href={`/bottling/new?honeyBatchId=${honeyBatch.id}`} />}
>
Neue Abfüllung
</Button>
</CardContent>
</Card>
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
@@ -133,17 +144,19 @@ export default function HoneyBatchDetailPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Rückverfolgbarkeit</CardTitle>
<Button
variant="outline"
size="sm"
disabled={exportPdfMutation.isPending}
onClick={() => exportPdfMutation.mutate()}
>
PDF-Bericht
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{!traceability ? <p className="text-muted-foreground">Lädt…</p> : null}
<p className="text-muted-foreground">
Woher der Honig dieser Charge stammt: Schleuderung, Ernten und Völker.
</p>
{traceabilityQuery.isLoading ? <LoadingState what="Herkunftsangaben" /> : null}
{traceabilityQuery.isError ? (
<ErrorState
what="Herkunftsangaben"
onRetry={() => void traceabilityQuery.refetch()}
isRetrying={traceabilityQuery.isFetching}
/>
) : null}
{traceability ? (
<>
<div className="flex items-center justify-between">
@@ -162,7 +175,7 @@ export default function HoneyBatchDetailPage() {
<Link
key={harvest.id}
href={`/harvests/${harvest.id}`}
className="rounded-md border p-3 hover:bg-muted"
className="min-h-11 rounded-md border p-3 hover:bg-muted"
>
<p className="font-medium">
Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")}