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:
1 parent
d4b1addb7d
commit
86f9291ee3
4 files changed
+121
-122
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>
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
<EquipmentEditDialog equipment={equipment} />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Reference in new issue
Block a user