feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops

- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchApiary } from "@/lib/apiaries/api";
import ApiaryDetailPage from "./page";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "a-1" }) }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiary: vi.fn() }));
vi.mock("@/lib/export/api", () => ({
exportApiaryAnnualReportPdf: vi.fn(),
exportTreatmentLogPdf: vi.fn(),
}));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/detail-header", () => ({
DetailHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
DetailQueryFallback: () => <p>Lädt</p>,
DetailStatus: () => null,
}));
vi.mock("./apiary-colonies-card", () => ({
ApiaryColoniesCard: () => null,
StartRoundButton: () => null,
}));
vi.mock("./apiary-edit-card", () => ({ ApiaryEditCard: () => null }));
vi.mock("./weather-card", () => ({ WeatherCard: () => null }));
vi.mock("@/components/phenology/apiary-season", () => ({
ApiarySeason: ({ apiary }: { apiary: { id: string; name: string } }) => (
<p>Saison von {apiary.name}</p>
),
}));
describe("ApiaryDetailPage", () => {
it("shows the season of the apiary below the header", async () => {
vi.mocked(fetchApiary).mockResolvedValue({
id: "a-1",
name: "Obstwiese",
location: null,
status: "ACTIVE",
latitude: null,
longitude: null,
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ApiaryDetailPage />
</QueryClientProvider>,
);
expect(await screen.findByText("Saison von Obstwiese")).toBeInTheDocument();
});
});
@@ -8,6 +8,7 @@ import { fetchApiary } from "@/lib/apiaries/api";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { ApiarySeason } from "@/components/phenology/apiary-season";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
import { ApiaryEditCard } from "./apiary-edit-card";
@@ -76,6 +77,7 @@ export default function ApiaryDetailPage() {
]}
{...BACK}
/>
<ApiarySeason apiary={{ id: apiary.id, name: apiary.name }} />
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<ApiaryColoniesCard apiary={{ id: apiary.id, name: apiary.name }} />
@@ -3,8 +3,9 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewExtractionPage from "./page";
import { createExtraction } from "@/lib/extractions/api";
import { createExtraction, fetchExtractionPrefill } from "@/lib/extractions/api";
import { fetchHarvests } from "@/lib/harvests/api";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
const push = vi.fn();
vi.mock("next/navigation", () => ({
@@ -12,7 +13,11 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/lib/harvests/api", () => ({ fetchHarvests: vi.fn() }));
vi.mock("@/lib/extractions/api", () => ({ createExtraction: vi.fn() }));
vi.mock("@/lib/extractions/api", () => ({
createExtraction: vi.fn(),
fetchExtractionPrefill: vi.fn(),
}));
vi.mock("@/lib/warnings/toast-warnings", () => ({ toastWarnings: vi.fn() }));
function renderPage() {
const client = new QueryClient({
@@ -35,7 +40,20 @@ describe("NewExtractionPage", () => {
date: "2026-07-01T00:00:00.000Z",
colonies: [{ colony: { code: "V-01" } }],
},
{
id: "harvest-2",
date: "2026-07-02T00:00:00.000Z",
colonies: [{ colony: { code: "V-02" } }],
},
] as never);
vi.mocked(fetchExtractionPrefill).mockReset();
vi.mocked(fetchExtractionPrefill).mockResolvedValue({
amountKg: null,
honeyType: null,
sources: [],
warnings: [],
});
vi.mocked(toastWarnings).mockReset();
vi.mocked(createExtraction).mockReset();
});
@@ -80,4 +98,75 @@ describe("NewExtractionPage", () => {
expect(screen.getByRole("group", { name: /^Ernten/ })).toHaveFocus();
expect(createExtraction).not.toHaveBeenCalled();
});
it("offers „Vorschlag aus 2 Ernten: 42,5 kg · übernehmen“ and fills only on tap", async () => {
vi.mocked(fetchExtractionPrefill).mockResolvedValue({
amountKg: 42.5,
honeyType: null,
sources: [],
warnings: [],
});
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
await userEvent.click(screen.getByRole("checkbox", { name: /V-02/ }));
expect(await screen.findByText(/Vorschlag aus 2 Ernten: 42,5 kg/)).toBeInTheDocument();
expect(fetchExtractionPrefill).toHaveBeenLastCalledWith(["harvest-1", "harvest-2"]);
expect(screen.getByLabelText(/Menge/)).toHaveValue(null);
await userEvent.click(screen.getByRole("button", { name: /übernehmen/ }));
expect(screen.getByLabelText(/Menge/)).toHaveValue(42.5);
});
it("keeps a note when the hints could not be loaded and lets the save through", async () => {
vi.mocked(fetchExtractionPrefill).mockRejectedValue(new Error("down"));
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
expect(
await screen.findByText(/Hinweise \(z\. B\. Wartezeit\) konnten nicht geladen werden/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schleuderung speichern" })).toBeEnabled();
});
it("shows the plausibility warnings above „Schleuderung speichern“", async () => {
vi.mocked(fetchExtractionPrefill).mockResolvedValue({
amountKg: null,
honeyType: null,
sources: [],
warnings: [
{
code: "WITHDRAWAL_ACTIVE",
severity: "WARNUNG",
message: "Volk V-01 ist noch in der Wartezeit.",
refs: [],
},
],
});
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
const warning = await screen.findByText("Volk V-01 ist noch in der Wartezeit.");
const save = screen.getByRole("button", { name: "Schleuderung speichern" });
expect(
warning.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the warnings of the save in a toast that stays", async () => {
const warnings = [
{ code: "WITHDRAWAL_ACTIVE", severity: "WARNUNG", message: "Wartezeit", refs: [] },
];
vi.mocked(createExtraction).mockResolvedValue({ id: "e-1", warnings } as never);
renderPage();
await userEvent.click(await screen.findByRole("checkbox", { name: /V-01/ }));
await userEvent.click(screen.getByRole("button", { name: "Schleuderung speichern" }));
expect(toastWarnings).toHaveBeenCalledWith(warnings, "Schleuderung gespeichert");
});
});
@@ -6,7 +6,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Droplet } from "lucide-react";
import { fetchHarvests } from "@/lib/harvests/api";
import { createExtraction } from "@/lib/extractions/api";
import {
createExtraction,
fetchExtractionPrefill,
} from "@/lib/extractions/api";
import { formatKg } from "@/lib/honey-batches/movements";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { WarningList } from "@/components/warnings/warning-list";
import { PrefillStatus } from "@/components/warnings/prefill-status";
import { PreviousValueHint } from "@/components/form/previous-value-hint";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
@@ -58,6 +66,16 @@ export default function NewExtractionPage() {
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("extraction-new", values, dirty);
// A proposal from the chosen harvests: shown with a take-over button, never
// written into the field by itself. Its warnings are the ones the save
// will answer, so they are visible before "Speichern".
const prefillQuery = useQuery({
queryKey: ["extractions", "prefill", values.harvestIds],
queryFn: () => fetchExtractionPrefill(values.harvestIds),
enabled: values.harvestIds.length > 0,
});
const prefill = values.harvestIds.length > 0 ? prefillQuery.data : undefined;
function set<K extends keyof ExtractionFormValues>(
key: K,
value: ExtractionFormValues[K],
@@ -83,7 +101,11 @@ export default function NewExtractionPage() {
onSuccess: (extraction) => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["extractions"] });
toast.success("Schleuderung gespeichert");
if (extraction.warnings?.length) {
toastWarnings(extraction.warnings, "Schleuderung gespeichert");
} else {
toast.success("Schleuderung gespeichert");
}
router.push(`/extractions/${extraction.id}`);
},
onError: (mutationError) => {
@@ -203,17 +225,32 @@ export default function NewExtractionPage() {
<FormSection step={2} title="Ergebnis">
<Field label="Menge (kg)" hint="Geschleuderter Honig, ohne Eimer.">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
min={0}
step="any"
className="h-11"
value={values.amount}
onChange={(event) => set("amount", event.target.value)}
/>
<>
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="decimal"
min={0}
step="any"
className="h-11"
value={values.amount}
onChange={(event) => set("amount", event.target.value)}
/>
{prefill?.amountKg != null &&
values.amount !== String(prefill.amountKg) ? (
<PreviousValueHint
fieldLabel="Menge"
valueLabel={formatKg(prefill.amountKg)}
prefix={`Vorschlag aus ${values.harvestIds.length} ${
values.harvestIds.length === 1 ? "Ernte" : "Ernten"
}`}
onTakeOver={() =>
set("amount", String(prefill.amountKg))
}
/>
) : null}
</>
)}
</Field>
<Field
@@ -267,6 +304,15 @@ export default function NewExtractionPage() {
</Field>
</FormSection>
<PrefillStatus
enabled={values.harvestIds.length > 0}
isLoading={prefillQuery.isLoading}
isError={prefillQuery.isError}
isRetrying={prefillQuery.isFetching}
onRetry={() => void prefillQuery.refetch()}
/>
<WarningList warnings={prefill?.warnings ?? []} />
<StickySubmit
note={
submitError ? (
@@ -0,0 +1,74 @@
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 { CorrectAmountDialog } from "./correct-amount-dialog";
import { bookHoneyBatchMovement } from "@/lib/honey-batches/api";
import { ApiError } from "@/lib/auth/api-client";
vi.mock("@/lib/honey-batches/api", () => ({ bookHoneyBatchMovement: vi.fn() }));
function renderDialog(amount = 20) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CorrectAmountDialog honeyBatchId="h1" amount={amount} />
</QueryClientProvider>,
);
}
describe("CorrectAmountDialog", () => {
beforeEach(() => {
vi.mocked(bookHoneyBatchMovement).mockReset();
});
it("books nothing while the weighed amount equals the booked one", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
expect(await screen.findByRole("button", { name: "Korrektur buchen" })).toBeDisabled();
});
it("says Keine Abweichung when nothing differs", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
expect(await screen.findByText("Keine Abweichung")).toBeInTheDocument();
});
it("shows the message of a 409 and the field text of a 400", async () => {
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
const input = await screen.findByLabelText(/Neu gewogen/);
await userEvent.clear(input);
await userEvent.type(input, "19");
vi.mocked(bookHoneyBatchMovement).mockRejectedValueOnce(new ApiError(409, "Gerade geändert.", {}));
await userEvent.click(screen.getByRole("button", { name: "Korrektur buchen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Gerade geändert.");
vi.mocked(bookHoneyBatchMovement).mockRejectedValueOnce(
new ApiError(400, "Bad", { issues: [{ field: "deltaKg", message: "Menge nicht erlaubt." }] }),
);
await userEvent.click(screen.getByRole("button", { name: "Korrektur buchen" }));
expect(await screen.findByText("Menge nicht erlaubt.")).toBeInTheDocument();
});
it("books a positive difference with the reason and shows a failed booking in the dialog", async () => {
vi.mocked(bookHoneyBatchMovement).mockRejectedValue(new Error("Boom"));
renderDialog();
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
const input = await screen.findByLabelText(/Neu gewogen/);
await userEvent.clear(input);
await userEvent.type(input, "21");
await userEvent.type(screen.getByLabelText(/Grund/), "Eimer nachgewogen");
await userEvent.click(screen.getByRole("button", { name: "Korrektur buchen" }));
expect(bookHoneyBatchMovement).toHaveBeenCalledWith(
"h1",
{ type: "CORRECTION", deltaKg: 1, notes: "Eimer nachgewogen" },
expect.anything(),
);
expect(await screen.findByRole("alert")).toHaveTextContent(/nicht gebucht/);
});
});
@@ -0,0 +1,172 @@
"use client";
import { FormEvent, useRef, useState } from "react";
import { Scale } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { bookHoneyBatchMovement } from "@/lib/honey-batches/api";
import { differenceKg, formatKg, formatSignedKg } from "@/lib/honey-batches/movements";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { MovementNotesField } from "./movement-notes-field";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface CorrectAmountDialogProps {
honeyBatchId: string;
/** The booked rest in kg. */
amount: number;
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
/**
* „Menge korrigieren“: the beekeeper types what the scale shows now, the
* booking is the difference to the booked rest (CORRECTION). Nothing is
* overwritten - the movement book keeps both numbers.
*/
export function CorrectAmountDialog({
honeyBatchId,
amount,
disabled = false,
describedBy: triggerDescribedBy,
}: CorrectAmountDialogProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
// The rest the difference is computed against: fixed when the dialog opens,
// so a reload behind it cannot move the booking.
const [baseline, setBaseline] = useState(amount);
const [weighed, setWeighed] = useState(String(amount));
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const idempotencyKey = useRef(newIdempotencyKey());
const { isOffline, hintId, describedBy } = useOnlineOnly();
const weighedKg = weighed === "" ? Number.NaN : Number(weighed);
const isNumber = Number.isFinite(weighedKg);
const isNegative = isNumber && weighedKg < 0;
const difference = isNumber && !isNegative ? differenceKg(weighedKg, baseline) : 0;
const differenceText = !isNumber
? "Bitte das Gewicht eingeben."
: isNegative
? "Das Gewicht kann nicht negativ sein."
: difference === 0
? "Keine Abweichung"
: `Differenz: ${formatSignedKg(difference)}`;
const mutation = useMutation({
mutationFn: () =>
bookHoneyBatchMovement(
honeyBatchId,
{
type: "CORRECTION",
deltaKg: difference,
...(emptyToNull(notes) ? { notes: notes.trim() } : {}),
},
{ idempotencyKey: idempotencyKey.current },
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
toast.success(`Menge korrigiert: ${formatSignedKg(difference)}`);
idempotencyKey.current = newIdempotencyKey();
setNotes("");
setOpen(false);
},
onError: (mutationError) => {
setError(saveErrorText(mutationError, "Die Korrektur wurde nicht gebucht."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (difference === 0 || isOffline) return;
mutation.mutate();
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setBaseline(amount);
setWeighed(String(amount));
setNotes("");
setError(null);
}
}}
>
<DialogTrigger
render={
<Button
variant="outline"
size="touch"
disabled={disabled}
aria-describedby={triggerDescribedBy}
>
<Scale className="size-4" aria-hidden="true" />
Menge korrigieren
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Menge korrigieren</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<p className="text-sm text-muted-foreground">
Gebucht sind {formatKg(baseline)}. Trag ein, was die Waage jetzt zeigt – gebucht wird
die Differenz.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="correct-amount-weighed">Neu gewogen (kg)</Label>
<Input
id="correct-amount-weighed"
type="number"
inputMode="decimal"
step="any"
className="h-11"
value={weighed}
onChange={(event) => setWeighed(event.target.value)}
/>
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{differenceText}
</p>
</div>
<MovementNotesField
id="correct-amount-reason"
label="Grund (optional)"
value={notes}
onChange={setNotes}
/>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={mutation.isPending || difference === 0 || isOffline}
aria-describedby={describedBy}
>
{mutation.isPending ? "Bucht…" : "Korrektur buchen"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HoneyBatchMovementsCard } from "./honey-batch-movements-card";
import { bookHoneyBatchMovement, fetchHoneyBatchMovementPage } from "@/lib/honey-batches/api";
vi.mock("@/lib/honey-batches/api", () => ({
fetchHoneyBatchMovementPage: vi.fn(),
bookHoneyBatchMovement: vi.fn(),
}));
const PAGE = {
hasMore: false,
items: [
{
id: "m2",
type: "CORRECTION",
quantityGrams: -500,
reason: null,
notes: "Nachgewogen",
sourceBottlingBatchId: null,
createdAt: "2026-10-02T10:00:00.000Z",
userId: "u1",
},
{
id: "m1",
type: "BOTTLING",
quantityGrams: -12500,
reason: null,
notes: null,
sourceBottlingBatchId: "bb1",
createdAt: "2026-10-01T10:00:00.000Z",
userId: "u1",
},
{
id: "m0",
type: "WITHDRAWAL",
quantityGrams: -1000,
reason: "PROBE",
notes: null,
sourceBottlingBatchId: null,
createdAt: "2026-10-01T09:00:00.000Z",
userId: "u1",
},
],
};
function renderCard(props: { canBook?: boolean; amount?: number | null } = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HoneyBatchMovementsCard
honeyBatchId="h1"
amount={props.amount === undefined ? 20 : props.amount}
canBook={props.canBook ?? true}
/>
</QueryClientProvider>,
);
}
describe("HoneyBatchMovementsCard", () => {
afterEach(() => act(() => reportRequestOutcome("reached-server")));
beforeEach(() => {
vi.mocked(fetchHoneyBatchMovementPage).mockReset();
vi.mocked(fetchHoneyBatchMovementPage).mockResolvedValue(PAGE as never);
vi.mocked(bookHoneyBatchMovement).mockReset();
vi.mocked(bookHoneyBatchMovement).mockResolvedValue({} as never);
});
it("lists bottlings, withdrawals and corrections with signed kilograms", async () => {
renderCard();
expect(await screen.findByText("−12,5 kg")).toBeInTheDocument();
expect(screen.getByText("Entnahme · Probe / Muster")).toBeInTheDocument();
expect(screen.getByText("Korrektur")).toBeInTheDocument();
expect(screen.getByText("Nachgewogen")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Abfüllung/ })).toHaveAttribute("href", "/bottling/bb1");
});
it("„Menge korrigieren“ books the difference to the weighed amount as CORRECTION", async () => {
renderCard();
await screen.findByText("−12,5 kg");
await userEvent.click(screen.getByRole("button", { name: /Menge korrigieren/ }));
const input = await screen.findByLabelText(/Neu gewogen/);
await userEvent.clear(input);
await userEvent.type(input, "18.5");
expect(screen.getByText(/Differenz: −1,5 kg/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Korrektur buchen" }));
expect(bookHoneyBatchMovement).toHaveBeenCalledWith(
"h1",
expect.objectContaining({ type: "CORRECTION", deltaKg: -1.5 }),
expect.anything(),
);
});
it("„Entnahme“ books a negative WITHDRAWAL with its reason", async () => {
renderCard();
await screen.findByText("−12,5 kg");
await userEvent.click(screen.getByRole("button", { name: /^Entnahme$/ }));
await userEvent.type(await screen.findByLabelText(/Menge \(kg\)/), "2");
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "Hofladen" }));
await userEvent.click(screen.getByRole("button", { name: "Entnahme buchen" }));
expect(bookHoneyBatchMovement).toHaveBeenCalledWith(
"h1",
expect.objectContaining({ type: "WITHDRAWAL", deltaKg: -2, reason: "HOFLADEN" }),
expect.anything(),
);
});
it("offers no booking without permission or without a tracked amount", async () => {
const { unmount } = renderCard({ canBook: false });
await screen.findByText("−12,5 kg");
expect(screen.queryByRole("button", { name: /Menge korrigieren/ })).not.toBeInTheDocument();
unmount();
renderCard({ amount: null });
await screen.findByText("−12,5 kg");
expect(screen.queryByRole("button", { name: /Menge korrigieren/ })).not.toBeInTheDocument();
});
it("switches the booking buttons off offline and says why", async () => {
renderCard();
await screen.findByText("−12,5 kg");
act(() => reportRequestOutcome("network-error"));
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
for (const name of [/Menge korrigieren/, /^Entnahme$/]) {
const button = screen.getByRole("button", { name });
expect(button).toBeDisabled();
expect(button).toHaveAccessibleDescription("Nur mit Verbindung");
}
});
it("says when older bookings are not shown", async () => {
vi.mocked(fetchHoneyBatchMovementPage).mockResolvedValue({ ...PAGE, hasMore: true } as never);
renderCard();
expect(await screen.findByText(/Ältere Buchungen werden nicht angezeigt/)).toBeInTheDocument();
});
});
@@ -0,0 +1,141 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { fetchHoneyBatchMovementPage } from "@/lib/honey-batches/api";
import {
formatSignedKg,
gramsToKg,
movementTitle,
} from "@/lib/honey-batches/movements";
import type { HoneyBatchMovement } from "@/lib/honey-batches/types";
import { ErrorState, LoadingState } from "@/components/query-state";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CorrectAmountDialog } from "./correct-amount-dialog";
import { HoneyBatchWithdrawDialog } from "./honey-batch-withdraw-dialog";
interface HoneyBatchMovementsCardProps {
honeyBatchId: string;
/** The booked rest in kg; `null` = not recorded, so nothing can be booked. */
amount: number | null;
canBook: boolean;
}
/**
* The movement book of a honey batch: every change of the rest, newest
* first - bottlings book themselves, loose goods and weighing again are
* booked by hand. The rest is the sum of these lines, so it cannot drift.
*/
export function HoneyBatchMovementsCard({
honeyBatchId,
amount,
canBook,
}: HoneyBatchMovementsCardProps) {
const query = useQuery({
queryKey: ["honey-batches", honeyBatchId, "movements"],
queryFn: () => fetchHoneyBatchMovementPage(honeyBatchId),
});
const page = query.data;
const { isOffline, hintId, describedBy } = useOnlineOnly();
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2.5">
<CardTitle>Bewegungen</CardTitle>
{canBook && amount !== null ? (
<div className="flex flex-wrap items-center gap-2">
<HoneyBatchWithdrawDialog
honeyBatchId={honeyBatchId}
amount={amount}
disabled={isOffline}
describedBy={describedBy}
/>
<CorrectAmountDialog
honeyBatchId={honeyBatchId}
amount={amount}
disabled={isOffline}
describedBy={describedBy}
/>
</div>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{canBook && amount !== null ? <OnlineOnlyHint id={hintId} /> : null}
{amount === null ? (
<p className="text-muted-foreground">
Für diese Charge ist keine Menge erfasst – es wird nichts gebucht.
</p>
) : null}
{query.isLoading ? <LoadingState what="Bewegungen" /> : null}
{query.isError ? (
<ErrorState
what="Bewegungen"
onRetry={() => void query.refetch()}
isRetrying={query.isFetching}
/>
) : null}
{page ? <MovementList movements={page.items} /> : null}
{page?.hasMore ? (
<p className="text-muted-foreground">
Ältere Buchungen werden nicht angezeigt.
</p>
) : null}
</CardContent>
</Card>
);
}
function MovementList({ movements }: { movements: HoneyBatchMovement[] }) {
if (movements.length === 0) {
return (
<p className="text-muted-foreground">
Noch keine Bewegungen – der Rest entspricht der Ausgangsmenge.
</p>
);
}
return (
<ul className="flex flex-col divide-y rounded-lg border">
{movements.map((movement) => (
<li
key={movement.id}
className="flex flex-wrap items-baseline justify-between gap-2 p-3"
>
<div className="flex flex-col">
<MovementTitle movement={movement} />
<span className="text-xs text-muted-foreground">
{new Date(movement.createdAt).toLocaleString("de-DE")}
</span>
{movement.notes ? (
<span className="text-xs text-muted-foreground">{movement.notes}</span>
) : null}
</div>
<span
className={
movement.quantityGrams < 0
? "font-semibold tabular-nums text-destructive"
: "font-semibold tabular-nums text-primary"
}
>
{formatSignedKg(gramsToKg(movement.quantityGrams))}
</span>
</li>
))}
</ul>
);
}
function MovementTitle({ movement }: { movement: HoneyBatchMovement }) {
const title = movementTitle(movement);
if (movement.sourceBottlingBatchId) {
return (
<Link
href={`/bottling/${movement.sourceBottlingBatchId}`}
className="min-h-11 content-center font-medium underline underline-offset-4"
>
{title}
</Link>
);
}
return <span className="font-medium">{title}</span>;
}
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HoneyBatchWithdrawDialog } from "./honey-batch-withdraw-dialog";
import { bookHoneyBatchMovement } from "@/lib/honey-batches/api";
import { ApiError } from "@/lib/auth/api-client";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
vi.mock("@/lib/honey-batches/api", () => ({ bookHoneyBatchMovement: vi.fn() }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HoneyBatchWithdrawDialog honeyBatchId="h1" amount={20} />
</QueryClientProvider>,
);
}
async function open() {
await userEvent.click(screen.getByRole("button", { name: /^Entnahme$/ }));
return screen.findByLabelText(/Menge \(kg\)/);
}
describe("HoneyBatchWithdrawDialog", () => {
beforeEach(() => vi.mocked(bookHoneyBatchMovement).mockReset());
afterEach(() => act(() => reportRequestOutcome("reached-server")));
it("has no preselected reason and says what is missing", async () => {
renderDialog();
const quantity = await open();
expect(screen.getByRole("combobox")).toHaveTextContent("Grund wählen");
await userEvent.type(quantity, "2");
expect(screen.getByText("Bitte einen Grund wählen.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
});
it("refuses more than the rest and less than a gram in words", async () => {
renderDialog();
const quantity = await open();
await userEvent.type(quantity, "25");
expect(screen.getByText("Mehr als der Rest (20 kg)")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
await userEvent.clear(quantity);
await userEvent.type(quantity, "0.0001");
expect(screen.getByText("Mindestens 1 g")).toBeInTheDocument();
});
it("shows the field texts of a 400 and the message of a 409", async () => {
renderDialog();
const quantity = await open();
await userEvent.type(quantity, "2");
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "Hofladen" }));
vi.mocked(bookHoneyBatchMovement).mockRejectedValueOnce(
new ApiError(400, "Bad", { issues: [{ field: "deltaKg", message: "Nicht genug Rest." }] }),
);
await userEvent.click(screen.getByRole("button", { name: "Entnahme buchen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Nicht genug Rest.");
vi.mocked(bookHoneyBatchMovement).mockRejectedValueOnce(
new ApiError(409, "Die Charge wurde gerade geändert.", {}),
);
await userEvent.click(screen.getByRole("button", { name: "Entnahme buchen" }));
expect(await screen.findByText("Die Charge wurde gerade geändert.")).toBeInTheDocument();
});
it("is off while offline and says why", async () => {
renderDialog();
const quantity = await open();
await userEvent.type(quantity, "2");
act(() => reportRequestOutcome("network-error"));
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
});
it("starts empty again when it is reopened", async () => {
renderDialog();
const quantity = await open();
await userEvent.type(quantity, "2");
await userEvent.keyboard("{Escape}");
expect(await open()).toHaveValue(null);
});
});
@@ -0,0 +1,180 @@
"use client";
import { FormEvent, useRef, useState } from "react";
import { PackageMinus } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { bookHoneyBatchMovement } from "@/lib/honey-batches/api";
import { saveErrorText } from "@/lib/honey-batches/errors";
import { formatKg } from "@/lib/honey-batches/movements";
import { WITHDRAWAL_REASON_LABELS } from "@/lib/stock/labels";
import type { WithdrawalReason } from "@/lib/stock/types";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { EnumSelect } from "@/components/form/enum-select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { MovementNotesField } from "./movement-notes-field";
interface HoneyBatchWithdrawDialogProps {
honeyBatchId: string;
/** The booked rest in kg. */
amount: number;
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
disabled?: boolean;
describedBy?: string;
}
const GRAMS_PER_KG = 1000;
/** What keeps the booking from being sent, in words; null = nothing. */
function problemWith(quantity: string, amount: number, reason: WithdrawalReason | ""): string | null {
const kg = Number(quantity);
if (quantity === "" || !Number.isFinite(kg)) return "Bitte eine Menge eingeben.";
if (Math.round(kg * GRAMS_PER_KG) < 1) return "Mindestens 1 g";
if (Math.round(kg * GRAMS_PER_KG) > Math.round(amount * GRAMS_PER_KG)) {
return `Mehr als der Rest (${formatKg(amount)})`;
}
if (reason === "") return "Bitte einen Grund wählen.";
return null;
}
/** „Entnahme“: loose goods, samples, breakage - books a negative WITHDRAWAL with its reason. */
export function HoneyBatchWithdrawDialog({
honeyBatchId,
amount,
disabled = false,
describedBy: triggerDescribedBy,
}: HoneyBatchWithdrawDialogProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [quantity, setQuantity] = useState("");
const [reason, setReason] = useState<WithdrawalReason | "">("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const idempotencyKey = useRef(newIdempotencyKey());
const { isOffline, hintId, describedBy } = useOnlineOnly();
const problem = problemWith(quantity, amount, reason);
const mutation = useMutation({
mutationFn: (chosenReason: WithdrawalReason) =>
bookHoneyBatchMovement(
honeyBatchId,
{
type: "WITHDRAWAL",
deltaKg: -Number(quantity),
reason: chosenReason,
...(emptyToNull(notes) ? { notes: notes.trim() } : {}),
},
{ idempotencyKey: idempotencyKey.current },
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
toast.success(`${formatKg(Number(quantity))} entnommen`);
idempotencyKey.current = newIdempotencyKey();
setOpen(false);
},
onError: (mutationError) => {
setError(saveErrorText(mutationError, "Die Entnahme wurde nicht gebucht."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (problem !== null || reason === "" || isOffline) return;
mutation.mutate(reason);
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setQuantity("");
setReason("");
setNotes("");
setError(null);
}
}}
>
<DialogTrigger
render={
<Button
variant="outline"
size="touch"
disabled={disabled || amount <= 0}
aria-describedby={triggerDescribedBy}
>
<PackageMinus className="size-4" aria-hidden="true" />
Entnahme
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Entnahme</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
<p className="text-sm text-muted-foreground">Noch da: {formatKg(amount)}.</p>
<div className="flex flex-col gap-2">
<Label htmlFor="honey-withdraw-quantity">Menge (kg)</Label>
<Input
id="honey-withdraw-quantity"
type="number"
inputMode="decimal"
step="any"
className="h-11"
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="honey-withdraw-reason">Grund</Label>
<EnumSelect
id="honey-withdraw-reason"
value={reason}
onChange={setReason}
noneLabel="Grund wählen"
labels={WITHDRAWAL_REASON_LABELS}
/>
</div>
<MovementNotesField
id="honey-withdraw-notes"
label="Notiz (optional)"
value={notes}
onChange={setNotes}
/>
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{problem}
</p>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<OnlineOnlyHint id={hintId} />
<Button
type="submit"
size="touch"
disabled={mutation.isPending || problem !== null || isOffline}
aria-describedby={describedBy}
>
{mutation.isPending ? "Bucht…" : "Entnahme buchen"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,33 @@
"use client";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
/** The server accepts at most 500 characters of notes per booking. */
export const MOVEMENT_NOTES_MAX = 500;
interface MovementNotesFieldProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
}
/** A note for a booking with a visible counter, so the limit is no surprise. */
export function MovementNotesField({ id, label, value, onChange }: MovementNotesFieldProps) {
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
<Textarea
id={id}
maxLength={MOVEMENT_NOTES_MAX}
aria-describedby={`${id}-count`}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
<p id={`${id}-count`} className="text-xs text-muted-foreground">
{value.length} / {MOVEMENT_NOTES_MAX}
</p>
</div>
);
}
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import HoneyBatchDetailPage from "./page";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchHoneyBatch, fetchHoneyBatchMovementPage } from "@/lib/honey-batches/api";
vi.mock("next/navigation", () => ({ useParams: () => ({ id: "h1" }) }));
vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn() }));
vi.mock("@/lib/honey-batches/api", () => ({
fetchHoneyBatch: vi.fn(),
fetchHoneyBatchTraceability: vi.fn(() => new Promise(() => undefined)),
fetchHoneyBatchMovementPage: vi.fn(),
bookHoneyBatchMovement: vi.fn(),
}));
vi.mock("./honey-batch-edit-dialog", () => ({ HoneyBatchEditDialog: () => null }));
vi.mock("./honey-batch-storage-card", () => ({ HoneyBatchStorageCard: () => null }));
vi.mock("./honey-batch-container-allocations-card", () => ({
HoneyBatchContainerAllocationsCard: () => null,
}));
vi.mock("./add-measurement-dialog", () => ({ AddMeasurementDialog: () => null }));
vi.mock("./edit-measurement-dialog", () => ({ EditMeasurementDialog: () => null }));
vi.mock("./extraction-edit-dialog", () => ({ ExtractionEditDialog: () => null }));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/lib/export/api", () => ({ exportHoneyBatchTraceabilityPdf: vi.fn() }));
function renderPage(permissions: string[]) {
vi.mocked(useAuth).mockReturnValue({
hasPermission: (permission: string) => permissions.includes(permission),
} as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HoneyBatchDetailPage />
</QueryClientProvider>,
);
}
describe("HoneyBatchDetailPage movements", () => {
beforeEach(() => {
vi.mocked(fetchHoneyBatch).mockResolvedValue({
id: "h1",
batchNumber: "H-1",
honeyType: null,
amount: 20,
waterContent: null,
status: "STORED",
measurements: [],
} as never);
vi.mocked(fetchHoneyBatchMovementPage).mockResolvedValue({ items: [], hasMore: false });
});
it("offers the bookings only with honey-batches:update", async () => {
const { unmount } = renderPage(["honey-batches:read", "honey-batches:update"]);
expect(await screen.findByRole("button", { name: /Menge korrigieren/ })).toBeInTheDocument();
unmount();
renderPage(["honey-batches:read"]);
await screen.findByText("Bewegungen");
expect(screen.queryByRole("button", { name: /Menge korrigieren/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Entnahme$/ })).not.toBeInTheDocument();
});
});
@@ -22,11 +22,15 @@ import { AddMeasurementDialog } from "./add-measurement-dialog";
import { EditMeasurementDialog } from "./edit-measurement-dialog";
import { ExtractionEditDialog } from "./extraction-edit-dialog";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
import { HoneyBatchMovementsCard } from "./honey-batch-movements-card";
import { useAuth } from "@/lib/auth/auth-context";
import { formatKg } from "@/lib/honey-batches/movements";
const BACK = { backHref: "/honey-batches", backLabel: "Alle Honigchargen" };
export default function HoneyBatchDetailPage() {
const params = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const honeyBatchQuery = useQuery({
queryKey: ["honey-batches", params.id],
queryFn: () => fetchHoneyBatch(params.id),
@@ -91,12 +95,17 @@ export default function HoneyBatchDetailPage() {
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{honeyBatch.honeyType ? <p>Sorte: {honeyBatch.honeyType}</p> : null}
{honeyBatch.amount !== null ? <p>Menge: {honeyBatch.amount} kg</p> : null}
{honeyBatch.amount !== null ? <p>Rest: {formatKg(honeyBatch.amount)}</p> : null}
{honeyBatch.waterContent !== null ? (
<p>Wassergehalt: {honeyBatch.waterContent}%</p>
) : null}
</CardContent>
</Card>
<HoneyBatchMovementsCard
honeyBatchId={honeyBatch.id}
amount={honeyBatch.amount}
canBook={hasPermission("honey-batches:update")}
/>
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
<HoneyBatchContainerAllocationsCard honeyBatch={honeyBatch} />
</div>
@@ -0,0 +1,137 @@
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 { CreateHoneyBatchDialog } from "./create-honey-batch-dialog";
import { fetchExtractions } from "@/lib/extractions/api";
import { createHoneyBatch, fetchHoneyBatchPrefill } from "@/lib/honey-batches/api";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { ApiError } from "@/lib/auth/api-client";
vi.mock("@/lib/extractions/api", () => ({ fetchExtractions: vi.fn() }));
vi.mock("@/lib/honey-batches/api", () => ({
createHoneyBatch: vi.fn(),
fetchHoneyBatchPrefill: vi.fn(),
}));
vi.mock("@/lib/warnings/toast-warnings", () => ({ toastWarnings: vi.fn() }));
function renderDialog() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<CreateHoneyBatchDialog />
</QueryClientProvider>,
);
}
async function openAndChooseExtraction() {
await userEvent.click(screen.getByRole("button", { name: "+ Neue Charge" }));
await userEvent.click(await screen.findByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: /V-01/ }));
}
describe("CreateHoneyBatchDialog", () => {
beforeEach(() => {
vi.mocked(fetchExtractions).mockResolvedValue([
{
id: "x1",
date: "2026-07-03T00:00:00.000Z",
harvests: [{ harvest: { colonies: [{ colony: { code: "V-01" } }] } }],
},
] as never);
vi.mocked(fetchHoneyBatchPrefill).mockReset();
vi.mocked(fetchHoneyBatchPrefill).mockResolvedValue({
remainingKg: 30,
honeyType: "Raps",
waterContent: 17.2,
warnings: [
{
code: "BATCHES_EXCEED_EXTRACTION",
severity: "WARNUNG",
message: "Die Chargen übersteigen die Schleuderung.",
refs: [],
},
],
});
vi.mocked(createHoneyBatch).mockReset();
vi.mocked(toastWarnings).mockReset();
});
it("offers the rest of the extraction and the honey type and fills only on tap", async () => {
renderDialog();
await openAndChooseExtraction();
expect(await screen.findByText(/Vorschlag aus der Schleuderung \(Rest\): 30 kg/)).toBeInTheDocument();
expect(screen.getByLabelText(/Menge/)).toHaveValue(null);
expect(screen.getByLabelText(/Honigsorte/)).toHaveValue("");
await userEvent.click(screen.getByRole("button", { name: /übernehmen.*Menge/ }));
await userEvent.click(screen.getByRole("button", { name: /übernehmen.*Honigsorte/ }));
expect(screen.getByLabelText(/Menge/)).toHaveValue(30);
expect(screen.getByLabelText(/Honigsorte/)).toHaveValue("Raps");
});
it("offers the water content as a proposal with its own take-over", async () => {
renderDialog();
await openAndChooseExtraction();
expect(await screen.findByText(/Vorschlag aus der Schleuderung: 17,2 %/)).toBeInTheDocument();
expect(screen.getByLabelText(/Wassergehalt/)).toHaveValue(null);
await userEvent.click(screen.getByRole("button", { name: /übernehmen.*Wassergehalt/ }));
expect(screen.getByLabelText(/Wassergehalt/)).toHaveValue(17.2);
});
it("does not hide a failed prefill: note stays, retry loads again, saving is possible", async () => {
vi.mocked(fetchHoneyBatchPrefill).mockRejectedValueOnce(new Error("down"));
renderDialog();
await openAndChooseExtraction();
expect(
await screen.findByText(/Hinweise \(z\. B\. Wartezeit\) konnten nicht geladen werden/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Anlegen" })).toBeEnabled();
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByText(/Vorschlag aus der Schleuderung \(Rest\)/)).toBeInTheDocument();
});
it("says that the hints are loading", async () => {
vi.mocked(fetchHoneyBatchPrefill).mockReturnValue(new Promise(() => undefined));
renderDialog();
await openAndChooseExtraction();
expect(await screen.findByText("Hinweise werden geladen …")).toBeInTheDocument();
});
it("puts a 400 issue next to its field and shows a 409 message", async () => {
renderDialog();
await openAndChooseExtraction();
vi.mocked(createHoneyBatch).mockRejectedValueOnce(
new ApiError(400, "Bad", { issues: [{ field: "amount", message: "Menge zu groß." }] }),
);
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(await screen.findByText("Menge zu groß.")).toBeInTheDocument();
vi.mocked(createHoneyBatch).mockRejectedValueOnce(new ApiError(409, "Chargennummer vergeben.", {}));
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
expect(await screen.findByText("Chargennummer vergeben.")).toBeInTheDocument();
});
it("shows the warnings above „Anlegen“ and keeps those of the save in a toast", async () => {
vi.mocked(createHoneyBatch).mockResolvedValue({ id: "b1", warnings: [{ code: "X" }] } as never);
renderDialog();
await openAndChooseExtraction();
const warning = await screen.findByText("Die Chargen übersteigen die Schleuderung.");
const save = screen.getByRole("button", { name: "Anlegen" });
expect(
warning.compareDocumentPosition(save) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await userEvent.click(save);
expect(createHoneyBatch).toHaveBeenCalled();
expect(toastWarnings).toHaveBeenCalledWith([{ code: "X" }], "Honigcharge angelegt");
});
});
@@ -3,7 +3,13 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchExtractions } from "@/lib/extractions/api";
import { createHoneyBatch } from "@/lib/honey-batches/api";
import { createHoneyBatch, fetchHoneyBatchPrefill } from "@/lib/honey-batches/api";
import { formatKg } from "@/lib/honey-batches/movements";
import { toastWarnings } from "@/lib/warnings/toast-warnings";
import { WarningList } from "@/components/warnings/warning-list";
import { PreviousValueHint } from "@/components/form/previous-value-hint";
import { PrefillStatus } from "@/components/warnings/prefill-status";
import { describeSaveError, type SaveError } from "@/lib/honey-batches/errors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -18,6 +24,14 @@ import {
import { RecordSelect } from "@/components/form/record-select";
import { emptyToNull } from "@/lib/forms/empty-to-null";
function FieldIssue({ message }: { message: string | undefined }) {
return message ? (
<p role="alert" className="text-sm font-medium text-destructive">
{message}
</p>
) : null;
}
export function CreateHoneyBatchDialog() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
@@ -25,34 +39,50 @@ export function CreateHoneyBatchDialog() {
const [extractionId, setExtractionId] = useState("");
const [honeyType, setHoneyType] = useState("");
const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null);
const [waterContent, setWaterContent] = useState("");
const [saveError, setSaveError] = useState<SaveError | null>(null);
const fieldError = (field: string) => saveError?.fields[field];
const { data: extractions } = useQuery({
queryKey: ["extractions"],
queryFn: fetchExtractions,
});
// Proposals from the extraction: shown with a take-over button, never
// written into a field by themselves.
const prefillQuery = useQuery({
queryKey: ["honey-batches", "prefill", extractionId],
queryFn: () => fetchHoneyBatchPrefill(extractionId),
enabled: extractionId !== "",
});
const prefill = extractionId ? prefillQuery.data : undefined;
const mutation = useMutation({
mutationFn: createHoneyBatch,
onSuccess: () => {
onSuccess: (honeyBatch) => {
if (honeyBatch.warnings?.length) {
toastWarnings(honeyBatch.warnings, "Honigcharge angelegt");
}
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
setOpen(false);
setBatchNumber("");
setExtractionId("");
setHoneyType("");
setAmount("");
setError(null);
setWaterContent("");
setSaveError(null);
},
onError: () => {
setError("Honigcharge konnte nicht angelegt werden.");
onError: (mutationError) => {
setSaveError(describeSaveError(mutationError, "Honigcharge konnte nicht angelegt werden."));
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSaveError(null);
if (!extractionId) {
setError("Bitte eine Schleuderung auswählen.");
setSaveError({ general: null, fields: { extractionId: "Bitte eine Schleuderung auswählen." } });
return;
}
mutation.mutate({
@@ -61,6 +91,7 @@ export function CreateHoneyBatchDialog() {
extractionId,
honeyType: emptyToNull(honeyType),
amount: emptyToNull(amount, Number),
waterContent: emptyToNull(waterContent, Number),
});
}
@@ -80,6 +111,7 @@ export function CreateHoneyBatchDialog() {
value={batchNumber}
onChange={(event) => setBatchNumber(event.target.value)}
/>
<FieldIssue message={fieldError("batchNumber")} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-extraction">Schleuderung</Label>
@@ -95,6 +127,7 @@ export function CreateHoneyBatchDialog() {
}
placeholder="Schleuderung wählen"
/>
<FieldIssue message={fieldError("extractionId")} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-honey-type">Honigsorte</Label>
@@ -103,6 +136,15 @@ export function CreateHoneyBatchDialog() {
value={honeyType}
onChange={(event) => setHoneyType(event.target.value)}
/>
{prefill?.honeyType && honeyType !== prefill.honeyType ? (
<PreviousValueHint
fieldLabel="Honigsorte"
valueLabel={prefill.honeyType}
prefix="Vorschlag aus den Ernten"
onTakeOver={() => setHoneyType(prefill.honeyType ?? "")}
/>
) : null}
<FieldIssue message={fieldError("honeyType")} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="batch-amount">Menge (kg)</Label>
@@ -113,10 +155,47 @@ export function CreateHoneyBatchDialog() {
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
{prefill?.remainingKg != null && amount !== String(prefill.remainingKg) ? (
<PreviousValueHint
fieldLabel="Menge"
valueLabel={formatKg(prefill.remainingKg)}
prefix="Vorschlag aus der Schleuderung (Rest)"
onTakeOver={() => setAmount(String(prefill.remainingKg))}
/>
) : null}
<FieldIssue message={fieldError("amount")} />
</div>
{error ? (
<div className="flex flex-col gap-2">
<Label htmlFor="batch-water-content">Wassergehalt (%)</Label>
<Input
id="batch-water-content"
type="number"
inputMode="decimal"
step="any"
value={waterContent}
onChange={(event) => setWaterContent(event.target.value)}
/>
{prefill?.waterContent != null && waterContent !== String(prefill.waterContent) ? (
<PreviousValueHint
fieldLabel="Wassergehalt"
valueLabel={`${prefill.waterContent.toLocaleString("de-DE")} %`}
prefix="Vorschlag aus der Schleuderung"
onTakeOver={() => setWaterContent(String(prefill.waterContent))}
/>
) : null}
<FieldIssue message={fieldError("waterContent")} />
</div>
<PrefillStatus
enabled={extractionId !== ""}
isLoading={prefillQuery.isLoading}
isError={prefillQuery.isError}
isRetrying={prefillQuery.isFetching}
onRetry={() => void prefillQuery.refetch()}
/>
<WarningList warnings={prefill?.warnings ?? []} />
{saveError?.general ? (
<p role="alert" className="text-sm text-destructive">
{error}
{saveError.general}
</p>
) : null}
<DialogFooter>