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>
@@ -19,4 +19,22 @@ describe("PreviousValueHint", () => {
expect(onTakeOver).toHaveBeenCalledTimes(1);
});
it("keeps „Letztes Mal“ as default prefix and lets a caller name the source", () => {
const { rerender } = render(
<PreviousValueHint fieldLabel="Menge" valueLabel="42,5 kg" onTakeOver={() => undefined} />,
);
expect(screen.getByText(/Letztes Mal: 42,5 kg/)).toBeInTheDocument();
rerender(
<PreviousValueHint
fieldLabel="Menge"
valueLabel="42,5 kg"
prefix="Aus 2 Ernten"
onTakeOver={() => undefined}
/>,
);
expect(screen.getByText(/Aus 2 Ernten: 42,5 kg/)).toBeInTheDocument();
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
});
});
@@ -8,6 +8,8 @@ export interface PreviousValueHintProps {
/** The earlier answer in words, e.g. "Ja" or "4 von 5". */
valueLabel: string;
onTakeOver: () => void;
/** Where the value comes from; "Letztes Mal" unless the caller names a source, e.g. "Aus 2 Ernten". */
prefix?: string;
className?: string;
}
@@ -20,12 +22,13 @@ export function PreviousValueHint({
fieldLabel,
valueLabel,
onTakeOver,
prefix = "Letztes Mal",
className,
}: PreviousValueHintProps) {
return (
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
<span>
Letztes Mal: {valueLabel} <span aria-hidden="true">·</span>
{prefix}: {valueLabel} <span aria-hidden="true">·</span>
</span>
<button
type="button"
@@ -0,0 +1,158 @@
import "fake-indexeddb/auto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchAutopilotToday } from "@/lib/autopilot/api";
import { createPhenologyReport, fetchPhenologyState } from "@/lib/phenology/api";
import type { PhenologyState } from "@/lib/phenology/types";
import { ApiarySeason } from "./apiary-season";
vi.mock("@/lib/phenology/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/phenology/api")>()),
fetchPhenologyState: vi.fn(),
fetchPhenologyReports: vi.fn().mockResolvedValue([]),
createPhenologyReport: vi.fn(),
}));
vi.mock("@/lib/autopilot/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/autopilot/api")>()),
fetchAutopilotToday: vi.fn(),
}));
const auth = vi.hoisted(() => ({ permissions: new Set<string>() }));
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({
hasPermission: (permission: string) => auth.permissions.has(permission),
}),
}));
const apiary = { id: "a-1", name: "Obstwiese" };
function state(overrides: Partial<PhenologyState>): PhenologyState {
return {
apiaryId: "a-1",
apiaryName: "Obstwiese",
phase: "KIRSCHE",
segment: "SCHWARMZEIT",
phaseSource: "REPORT",
reportedOn: "2026-04-18",
gts: null,
gtsThroughDay: null,
note: null,
...overrides,
};
}
function renderSeason() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ApiarySeason apiary={apiary} />
</QueryClientProvider>,
);
}
beforeEach(() => {
auth.permissions = new Set(["phenology:create"]);
vi.mocked(fetchPhenologyState)
.mockReset()
.mockResolvedValue([
state({ apiaryId: "a-2", apiaryName: "Wald", segment: "SOMMER" }),
state({}),
]);
vi.mocked(fetchAutopilotToday)
.mockReset()
.mockResolvedValue({
nextStep: null,
inspectionDue: [
{
apiaryId: "a-1",
apiaryName: "Obstwiese",
segment: "SCHWARMZEIT",
intervalDays: 7,
colonies: [],
},
],
suggestionsOpen: 0,
seasons: [],
tasksVisible: true,
});
});
describe("ApiarySeason", () => {
it("shows the season with its source on the apiary page", async () => {
renderSeason();
expect(await screen.findByText("Schwarmzeit · alle 7 Tage")).toBeInTheDocument();
expect(screen.getByText("gemeldet am 18.04.")).toBeInTheDocument();
expect(screen.getByText("Kirsche blüht")).toBeInTheDocument();
});
it("names an estimated phase and a calendar phase by their source", async () => {
vi.mocked(fetchPhenologyState).mockResolvedValue([
state({ phaseSource: "GTS", reportedOn: null, phase: "APFEL" }),
]);
const { unmount } = renderSeason();
expect(await screen.findByText("geschätzt")).toBeInTheDocument();
unmount();
vi.mocked(fetchPhenologyState).mockResolvedValue([
state({ phaseSource: "CALENDAR", reportedOn: null, phase: null }),
]);
renderSeason();
expect(await screen.findByText("Kalender")).toBeInTheDocument();
});
it("falls back to the season without an interval when the interval is unknown", async () => {
vi.mocked(fetchAutopilotToday).mockRejectedValue(new Error("403"));
renderSeason();
expect(await screen.findByText("Schwarmzeit")).toBeInTheDocument();
});
it("shows „Pflanze blüht“ only with phenology:create and opens the sheet", async () => {
renderSeason();
await userEvent.click(await screen.findByRole("button", { name: "Pflanze blüht" }));
expect(await screen.findByRole("dialog")).toHaveTextContent("Entwicklung melden");
expect(screen.getByRole("button", { name: "Salweide blüht" })).toBeInTheDocument();
});
it("hides the button without phenology:create", async () => {
auth.permissions = new Set();
renderSeason();
await screen.findByText("Schwarmzeit · alle 7 Tage");
expect(screen.queryByRole("button", { name: "Pflanze blüht" })).toBeNull();
});
it("explains a missing weather basis in words", async () => {
vi.mocked(fetchPhenologyState).mockResolvedValue([
state({
phaseSource: "CALENDAR",
reportedOn: null,
note: "WEATHER_STALE",
}),
]);
renderSeason();
expect(await screen.findByText("Wetterdaten veraltet")).toBeInTheDocument();
});
it("closes the sheet after a report and returns the focus to „Pflanze blüht“", async () => {
vi.mocked(createPhenologyReport).mockResolvedValue({} as never);
renderSeason();
const trigger = await screen.findByRole("button", {
name: "Pflanze blüht",
});
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole("button", { name: "Salweide blüht" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(trigger).toHaveFocus());
});
});
@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Flower2 } from "lucide-react";
import { SeasonChip } from "@/components/autopilot/season-chip";
import { PhenologyReportSheet } from "@/components/phenology/phenology-report-sheet";
import { Button } from "@/components/ui/button";
import { autopilotTodayKey, fetchAutopilotToday } from "@/lib/autopilot/api";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchPhenologyState, phenologyStateKey } from "@/lib/phenology/api";
import { PHASE_REPORT_LABELS, SEASON_NOTE_LABELS, phaseSourceText } from "@/lib/phenology/labels";
interface ApiarySeasonProps {
apiary: { id: string; name: string };
}
/**
* The season of an apiary with where it comes from („Schwarmzeit · alle 7
* Tage · gemeldet am 18.04.“), and the way to report a blooming plant.
*/
export function ApiarySeason({ apiary }: ApiarySeasonProps) {
const { hasPermission } = useAuth();
const [isSheetOpen, setSheetOpen] = useState(false);
const stateQuery = useQuery({
queryKey: phenologyStateKey,
queryFn: fetchPhenologyState,
});
// The interval is only known for apiaries with colonies to inspect; without
// it (or without the right to see it) the chip names just the season.
const todayQuery = useQuery({
queryKey: autopilotTodayKey,
queryFn: fetchAutopilotToday,
});
const state = stateQuery.data?.find((entry) => entry.apiaryId === apiary.id);
const intervalDays =
todayQuery.data?.inspectionDue.find((entry) => entry.apiaryId === apiary.id)?.intervalDays ??
null;
const source = state ? phaseSourceText(state.phaseSource, state.reportedOn) : null;
const canReport = hasPermission("phenology:create");
if (!state && !canReport) return null;
return (
<section aria-label="Saison" className="flex flex-wrap items-center gap-x-3 gap-y-2">
{state ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<SeasonChip segment={state.segment} intervalDays={intervalDays} />
{state.phase ? <span>{PHASE_REPORT_LABELS[state.phase]}</span> : null}
{source ? (
<>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">{source}</span>
</>
) : null}
{state.note ? (
<span className="text-warning-foreground">{SEASON_NOTE_LABELS[state.note]}</span>
) : null}
</div>
) : null}
{canReport ? (
<>
<Button type="button" size="touch" variant="outline" onClick={() => setSheetOpen(true)}>
<Flower2 aria-hidden />
Pflanze blüht
</Button>
<PhenologyReportSheet open={isSheetOpen} onOpenChange={setSheetOpen} apiary={apiary} />
</>
) : null}
</section>
);
}
@@ -0,0 +1,356 @@
import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { ApiError } from "@/lib/auth/api-client";
import { berlinCalendarDay } from "@/lib/dates";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import {
createPhenologyReport,
fetchPhenologyReports,
queuePhenologyReport,
revokePhenologyReport,
} from "@/lib/phenology/api";
import type { PhenologyReport } from "@/lib/phenology/types";
import { PhenologyReportSheet } from "./phenology-report-sheet";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/phenology/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/phenology/api")>()),
createPhenologyReport: vi.fn(),
fetchPhenologyReports: vi.fn(),
queuePhenologyReport: vi.fn(),
revokePhenologyReport: vi.fn(),
}));
const auth = vi.hoisted(() => ({ permissions: new Set<string>() }));
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({
hasPermission: (permission: string) => auth.permissions.has(permission),
}),
}));
const apiary = { id: "a-1", name: "Obstwiese" };
function report(overrides: Partial<PhenologyReport>): PhenologyReport {
return {
id: "r-1",
apiaryId: null,
apiaryName: null,
phase: "SALWEIDE",
date: "2026-03-20",
notes: null,
userId: "user-2",
userName: "Karla",
revokedAt: null,
createdAt: "2026-03-20T08:00:00.000Z",
...overrides,
};
}
function renderSheet(onOpenChange = vi.fn()) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<PhenologyReportSheet open onOpenChange={onOpenChange} apiary={apiary} />
</QueryClientProvider>,
);
return onOpenChange;
}
const today = () => berlinCalendarDay(new Date().toISOString());
beforeEach(() => {
auth.permissions = new Set(["phenology:create", "phenology:read", "phenology:update"]);
vi.mocked(createPhenologyReport).mockReset().mockResolvedValue(report({}));
vi.mocked(fetchPhenologyReports).mockReset().mockResolvedValue([]);
vi.mocked(queuePhenologyReport).mockReset().mockResolvedValue(undefined);
vi.mocked(revokePhenologyReport)
.mockReset()
.mockResolvedValue(report({ revokedAt: "x" }));
vi.mocked(toast.success).mockReset();
});
afterEach(() => {
vi.useRealTimers();
act(() => reportRequestOutcome("reached-server"));
});
describe("PhenologyReportSheet", () => {
it("one tap reports the phase for today for all apiaries", async () => {
const onOpenChange = renderSheet();
await userEvent.click(screen.getByRole("button", { name: "Salweide blüht" }));
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(1));
expect(createPhenologyReport).toHaveBeenCalledWith(
{ phase: "SALWEIDE", date: today(), apiaryId: null },
{ idempotencyKey: expect.any(String) },
);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Salweide blüht gemeldet"));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("rows are 56 px and „für alle Stände“ is preselected", () => {
renderSheet();
expect(screen.getByRole("radio", { name: "Für alle Stände" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Nur Obstwiese" })).not.toBeChecked();
const rows = screen.getAllByRole("button", {
name: /(blüht|vorbei)$|^Winterruhe$/,
});
expect(rows).toHaveLength(8);
for (const row of rows) {
expect(row).toHaveClass("min-h-14");
}
});
it("reports for this apiary only after switching the scope", async () => {
renderSheet();
await userEvent.click(screen.getByRole("radio", { name: "Nur Obstwiese" }));
await userEvent.click(screen.getByRole("button", { name: "Kirsche blüht" }));
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(1));
expect(vi.mocked(createPhenologyReport).mock.calls[0][0]).toMatchObject({
phase: "KIRSCHE",
apiaryId: "a-1",
});
});
it("sends a different key for a different phase in the same opened sheet", async () => {
vi.mocked(createPhenologyReport).mockRejectedValue(new ApiError(500, "Fehler", {}));
renderSheet();
await userEvent.click(screen.getByRole("button", { name: "Salweide blüht" }));
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(1));
await userEvent.click(screen.getByRole("button", { name: "Apfel blüht" }));
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(2));
const [first, second] = vi.mocked(createPhenologyReport).mock.calls;
expect(first[1]?.idempotencyKey).not.toBe(second[1]?.idempotencyKey);
});
it("offline the report is queued and synced later", async () => {
vi.mocked(createPhenologyReport).mockRejectedValue(new TypeError("Failed to fetch"));
const onOpenChange = renderSheet();
await userEvent.click(screen.getByRole("button", { name: "Holunder blüht" }));
await waitFor(() => expect(queuePhenologyReport).toHaveBeenCalledTimes(1));
const [input, key, owner] = vi.mocked(queuePhenologyReport).mock.calls[0];
expect(input).toEqual({ phase: "HOLUNDER", date: today(), apiaryId: null });
expect(key).toBe(vi.mocked(createPhenologyReport).mock.calls[0][1]?.idempotencyKey);
expect(owner).toBe("user-1");
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
"Holunder blüht gespeichert – wird gesendet, sobald wieder Verbindung besteht",
),
);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("shows the reason of the server and stays open when the report is rejected", async () => {
vi.mocked(createPhenologyReport).mockRejectedValue(
new ApiError(400, "Das Datum liegt in der Zukunft.", {}),
);
const onOpenChange = renderSheet();
await userEvent.click(screen.getByRole("button", { name: "Linde blüht" }));
expect(await screen.findByText("Das Datum liegt in der Zukunft.")).toBeVisible();
expect(queuePhenologyReport).not.toHaveBeenCalled();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("lists this year's reports that count for the apiary and withdraws one", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([
report({ id: "r-1", phase: "SALWEIDE", date: "2026-03-20" }),
report({
id: "r-2",
phase: "KIRSCHE",
date: "2026-04-10",
apiaryId: "a-1",
apiaryName: "Obstwiese",
userName: "Nick",
}),
report({
id: "r-3",
phase: "APFEL",
date: "2026-04-20",
apiaryId: "a-9",
apiaryName: "Wald",
}),
report({
id: "r-4",
phase: "LINDE",
date: "2026-06-01",
revokedAt: "2026-06-02T08:00:00.000Z",
}),
]);
renderSheet();
const list = await screen.findByRole("list", {
name: "Meldungen dieses Jahres",
});
const items = within(list).getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(items[0]).toHaveTextContent("Salweide blüht");
expect(items[0]).toHaveTextContent("20.03.");
expect(items[0]).toHaveTextContent("alle Stände");
expect(items[1]).toHaveTextContent("Kirsche blüht");
expect(items[1]).toHaveTextContent("Obstwiese");
expect(items[2]).toHaveTextContent("zurückgezogen");
expect(within(items[2]).queryByRole("button", { name: /Zurückziehen/ })).toBeNull();
await userEvent.click(within(items[1]).getByRole("button", { name: /Zurückziehen/ }));
await waitFor(() =>
expect(revokePhenologyReport).toHaveBeenCalledWith("r-2", {
idempotencyKey: expect.any(String),
}),
);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Meldung zurückgezogen"));
});
it("does not load the list without phenology:read", () => {
auth.permissions = new Set(["phenology:create"]);
renderSheet();
expect(screen.queryByRole("list", { name: "Meldungen dieses Jahres" })).toBeNull();
expect(fetchPhenologyReports).not.toHaveBeenCalled();
});
it("offers „Zurückziehen“ only with phenology:update", async () => {
auth.permissions = new Set(["phenology:create", "phenology:read"]);
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({})]);
renderSheet();
await screen.findByRole("list", { name: "Meldungen dieses Jahres" });
expect(screen.queryByRole("button", { name: /Zurückziehen/ })).toBeNull();
});
it("switches off „Zurückziehen“ with „Nur mit Verbindung“ offline", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({})]);
renderSheet();
const withdraw = await screen.findByRole("button", {
name: /zurückziehen/i,
});
act(() => reportRequestOutcome("network-error"));
expect(withdraw).toBeDisabled();
expect(screen.getByText("Nur mit Verbindung")).toBeInTheDocument();
});
it("asks first before withdrawing a report for all apiaries", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
renderSheet();
await userEvent.click(
await screen.findByRole("button", {
name: "Für alle Stände zurückziehen: Salweide blüht vom 20.03.",
}),
);
expect(revokePhenologyReport).not.toHaveBeenCalled();
const dialog = await screen.findByRole("dialog", {
name: "Für alle Stände zurückziehen?",
});
await userEvent.click(within(dialog).getByRole("button", { name: "Zurückziehen" }));
await waitFor(() =>
expect(revokePhenologyReport).toHaveBeenCalledWith("r-1", {
idempotencyKey: expect.any(String),
}),
);
});
it("does not withdraw when the question is cancelled", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([report({ id: "r-1" })]);
renderSheet();
await userEvent.click(
await screen.findByRole("button", {
name: /Für alle Stände zurückziehen/,
}),
);
const dialog = await screen.findByRole("dialog", {
name: "Für alle Stände zurückziehen?",
});
await userEvent.click(within(dialog).getByRole("button", { name: "Abbrechen" }));
expect(revokePhenologyReport).not.toHaveBeenCalled();
});
it("moves the focus to the list heading after a withdrawal", async () => {
vi.mocked(fetchPhenologyReports).mockResolvedValue([
report({ id: "r-2", apiaryId: "a-1", apiaryName: "Obstwiese" }),
]);
renderSheet();
await userEvent.click(await screen.findByRole("button", { name: /Zurückziehen/ }));
await waitFor(() =>
expect(screen.getByRole("heading", { name: "Meldungen dieses Jahres" })).toHaveFocus(),
);
});
it("has a neutral heading", () => {
renderSheet();
expect(screen.getByRole("dialog", { name: "Entwicklung melden" })).toBeInTheDocument();
});
it("closes with Escape", async () => {
const onOpenChange = renderSheet();
await userEvent.keyboard("{Escape}");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("stays open while the report is on its way", async () => {
let answer: (saved: PhenologyReport) => void = () => undefined;
vi.mocked(createPhenologyReport).mockReturnValue(
new Promise<PhenologyReport>((resolve) => {
answer = resolve;
}),
);
const onOpenChange = renderSheet();
await userEvent.click(screen.getByRole("button", { name: "Salweide blüht" }));
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalled());
await userEvent.keyboard("{Escape}");
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Schließen" })).toBeDisabled();
await act(async () => answer(report({})));
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
});
it("says „Nur mit Verbindung“ instead of a load error offline", async () => {
vi.mocked(fetchPhenologyReports).mockRejectedValue(new TypeError("Failed to fetch"));
renderSheet();
act(() => reportRequestOutcome("network-error"));
expect(await screen.findByText("Nur mit Verbindung")).toBeInTheDocument();
expect(screen.queryByText("Die Meldungen konnten nicht geladen werden.")).toBeNull();
});
it.each([
["summer time", "2026-07-10T23:30:00.000Z", "2026-07-11"],
["winter time", "2026-01-10T23:30:00.000Z", "2026-01-11"],
])("reports the Berlin day at 23:30 UTC in %s", async (_name, now, berlinDay) => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(now));
renderSheet();
await userEvent.click(screen.getByRole("button", { name: "Linde blüht" }));
await waitFor(() => expect(createPhenologyReport).toHaveBeenCalledTimes(1));
expect(vi.mocked(createPhenologyReport).mock.calls[0][0].date).toBe(berlinDay);
});
});
@@ -0,0 +1,343 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Flower2 } from "lucide-react";
import { toast } from "sonner";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { autopilotTodayKey } from "@/lib/autopilot/api";
import { formatDayMonth } from "@/lib/autopilot/format";
import { useAuth } from "@/lib/auth/auth-context";
import { berlinCalendarDay } from "@/lib/dates";
import { errorMessage } from "@/lib/forms/error-message";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import {
createPhenologyReport,
fetchPhenologyReports,
phenologyReportsKey,
queuePhenologyReport,
revokePhenologyReport,
} from "@/lib/phenology/api";
import { PHASE_REPORT_LABELS } from "@/lib/phenology/labels";
import {
PHENOLOGY_PHASES,
type CreatePhenologyReportInput,
type PhenologyReport,
} from "@/lib/phenology/types";
import { cn } from "@/lib/utils";
interface PhenologyReportSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
apiary: { id: string; name: string };
}
type Scope = "all" | "apiary";
const QUEUED_TEXT = "gespeichert – wird gesendet, sobald wieder Verbindung besteht";
/** Which plant is in bloom? One tap reports it for today. */
export function PhenologyReportSheet({ open, onOpenChange, apiary }: PhenologyReportSheetProps) {
// While a report or a withdrawal is on its way the sheet stays open, so the
// answer (or the server's reason) is never lost behind a closed sheet.
const [isBusy, setBusy] = useState(false);
function handleOpenChange(next: boolean) {
if (!next && isBusy) return;
onOpenChange(next);
}
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="bottom"
showCloseButton={false}
className="max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)"
>
{/* Remounted per opening: a fresh idempotency key and the preselected scope. */}
{open ? (
<ReportForm
apiary={apiary}
onOpenChange={onOpenChange}
isBusy={isBusy}
onBusyChange={setBusy}
/>
) : null}
</SheetContent>
</Sheet>
);
}
function ReportForm({
apiary,
onOpenChange,
isBusy,
onBusyChange,
}: Pick<PhenologyReportSheetProps, "apiary" | "onOpenChange"> & {
isBusy: boolean;
onBusyChange: (busy: boolean) => void;
}) {
const queryClient = useQueryClient();
const [scope, setScope] = useState<Scope>("all");
const capture = useCaptureMutation<CreatePhenologyReportInput, unknown>({
create: createPhenologyReport,
queue: queuePhenologyReport,
failureMessage: "Die Meldung konnte nicht gespeichert werden.",
// Each phase and scope is its own report and needs its own key.
keyPerSubmission: true,
onSaved: (outcome, input) => {
const label = PHASE_REPORT_LABELS[input.phase];
if (outcome.via === "online") {
toast.success(`${label} gemeldet`);
void queryClient.invalidateQueries({ queryKey: ["phenology"] });
void queryClient.invalidateQueries({ queryKey: autopilotTodayKey });
} else {
toast.success(`${label} ${QUEUED_TEXT}`);
}
onOpenChange(false);
},
});
const [revokeError, setRevokeError] = useState<string | null>(null);
const [isRevoking, setRevoking] = useState(false);
const status = capture.submitError ?? revokeError;
const busy = capture.isPending || isRevoking;
useEffect(() => {
onBusyChange(busy);
return () => onBusyChange(false);
}, [busy, onBusyChange]);
return (
<div className="flex flex-col gap-4 px-4 pb-4">
<SheetHeader className="px-0">
<SheetTitle>Entwicklung melden</SheetTitle>
<SheetDescription>Ein Tipp meldet die Pflanze für heute.</SheetDescription>
</SheetHeader>
<ScopeChoice apiaryName={apiary.name} scope={scope} onChange={setScope} />
<div role="group" aria-label="Pflanze" className="flex flex-col gap-2">
{PHENOLOGY_PHASES.map((phase) => (
<button
key={phase}
type="button"
disabled={capture.isPending}
onClick={() =>
capture.submit({
phase,
date: berlinCalendarDay(new Date().toISOString()),
apiaryId: scope === "all" ? null : apiary.id,
})
}
className={cn(
"flex min-h-14 w-full items-center gap-3 rounded-xl border border-border bg-background px-4",
"text-left text-base font-medium outline-none transition-colors",
"hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:opacity-50",
)}
>
<Flower2 className="size-5 shrink-0 text-muted-foreground" aria-hidden />
{PHASE_REPORT_LABELS[phase]}
</button>
))}
</div>
{/* Always mounted, so a screen reader announces the text when it appears. */}
<p role="status" aria-live="polite" className="min-h-5 text-sm text-destructive">
{status}
</p>
<ReportsOfTheYear
apiary={apiary}
onRevokeError={setRevokeError}
onRevokingChange={setRevoking}
/>
<SheetClose
disabled={isBusy}
render={<Button type="button" size="touch" variant="outline" />}
>
Schließen
</SheetClose>
</div>
);
}
function ScopeChoice({
apiaryName,
scope,
onChange,
}: {
apiaryName: string;
scope: Scope;
onChange: (scope: Scope) => void;
}) {
const options: { value: Scope; label: string }[] = [
{ value: "all", label: "Für alle Stände" },
{ value: "apiary", label: `Nur ${apiaryName}` },
];
return (
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-sm font-medium">Gilt für</legend>
{options.map((option) => (
<label
key={option.value}
className="flex min-h-11 items-center gap-3 rounded-lg px-1 text-base has-focus-visible:ring-3 has-focus-visible:ring-ring/50"
>
<input
type="radio"
name="phenology-scope"
className="size-5 accent-primary"
checked={scope === option.value}
onChange={() => onChange(option.value)}
/>
{option.label}
</label>
))}
</fieldset>
);
}
/** The reports that count for this apiary: its own and those for all apiaries. */
function appliesTo(report: PhenologyReport, apiaryId: string): boolean {
return report.apiaryId === null || report.apiaryId === apiaryId;
}
function ReportsOfTheYear({
apiary,
onRevokeError,
onRevokingChange,
}: {
apiary: { id: string };
onRevokeError: (message: string | null) => void;
onRevokingChange: (isRevoking: boolean) => void;
}) {
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const year = Number(berlinCalendarDay(new Date().toISOString()).slice(0, 4));
const canRead = hasPermission("phenology:read");
const canWithdraw = hasPermission("phenology:update");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const heading = useRef<HTMLHeadingElement>(null);
const reports = useQuery({
queryKey: phenologyReportsKey(year),
queryFn: () => fetchPhenologyReports(year),
enabled: canRead,
});
const revoke = useMutation({
mutationFn: (id: string) => revokePhenologyReport(id, { idempotencyKey: newIdempotencyKey() }),
onMutate: () => onRevokeError(null),
onSuccess: () => {
toast.success("Meldung zurückgezogen");
void queryClient.invalidateQueries({ queryKey: ["phenology"] });
void queryClient.invalidateQueries({ queryKey: autopilotTodayKey });
heading.current?.focus();
},
onError: (error) =>
onRevokeError(errorMessage(error, "Die Meldung konnte nicht zurückgezogen werden.")),
});
useEffect(() => {
onRevokingChange(revoke.isPending);
}, [revoke.isPending, onRevokingChange]);
if (!canRead) return null;
const items = (reports.data ?? []).filter((report) => appliesTo(report, apiary.id));
return (
<section className="flex flex-col gap-2" aria-labelledby="phenology-reports-heading">
<h3
id="phenology-reports-heading"
ref={heading}
tabIndex={-1}
className="text-sm font-medium outline-none"
>
Meldungen dieses Jahres
</h3>
{reports.isError && !isOffline ? (
<p className="text-sm text-destructive">Die Meldungen konnten nicht geladen werden.</p>
) : null}
{reports.isSuccess && items.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Meldung in diesem Jahr.</p>
) : null}
{items.length > 0 ? (
<ul aria-label="Meldungen dieses Jahres" className="flex flex-col gap-2">
{items.map((report) => (
<ReportItem
key={report.id}
report={report}
canWithdraw={canWithdraw}
disabled={isOffline || revoke.isPending}
describedBy={describedBy}
onWithdraw={() => revoke.mutate(report.id)}
/>
))}
</ul>
) : null}
<OnlineOnlyHint id={hintId} />
</section>
);
}
function ReportItem({
report,
canWithdraw,
disabled,
describedBy,
onWithdraw,
}: {
report: PhenologyReport;
canWithdraw: boolean;
disabled: boolean;
describedBy: string | undefined;
onWithdraw: () => void;
}) {
const label = PHASE_REPORT_LABELS[report.phase];
const day = formatDayMonth(report.date);
const isRevoked = report.revokedAt !== null;
const isForAll = report.apiaryId === null;
const withdrawLabel = `${label} vom ${day}`;
return (
<li className="flex min-h-14 items-center justify-between gap-3 rounded-xl border border-border px-4 py-2">
<div className="min-w-0">
<p className={cn("font-medium", isRevoked && "text-muted-foreground line-through")}>
{label}
</p>
<p className="text-sm text-muted-foreground">
{day} · {report.apiaryName ?? "alle Stände"} · {report.userName}
{isRevoked ? " · zurückgezogen" : ""}
</p>
</div>
{canWithdraw && !isRevoked && isForAll ? (
// A report for all apiaries changes the season of every apiary: ask first.
<ConfirmDialog
triggerLabel="Für alle Stände zurückziehen"
triggerAriaLabel={`Für alle Stände zurückziehen: ${withdrawLabel}`}
triggerSize="touch"
triggerDisabled={disabled}
title="Für alle Stände zurückziehen?"
description={`${withdrawLabel} gilt für alle Stände. Beim Zurückziehen wird die Saison aller Stände neu berechnet.`}
confirmLabel="Zurückziehen"
onConfirm={onWithdraw}
/>
) : null}
{canWithdraw && !isRevoked && !isForAll ? (
<Button
type="button"
size="touch"
variant="outline"
disabled={disabled}
aria-describedby={describedBy}
aria-label={`Zurückziehen: ${withdrawLabel}`}
onClick={onWithdraw}
>
Zurückziehen
</Button>
) : null}
</li>
);
}
@@ -0,0 +1,36 @@
"use client";
import { Button } from "@/components/ui/button";
export interface PrefillStatusProps {
/** The prefill is asked for at all (something is chosen). */
enabled: boolean;
isLoading: boolean;
isError: boolean;
isRetrying: boolean;
onRetry: () => void;
}
/**
* What the prefill is doing, so that a missing warning never reads as "no
* warning": while loading a short line, after a failure a note that stays
* until a retry works. Saving stays possible - the server checks anyway. The
* region stays mounted so the change is announced.
*/
export function PrefillStatus({ enabled, isLoading, isError, isRetrying, onRetry }: PrefillStatusProps) {
return (
<div role="status" aria-live="polite">
{enabled && isLoading ? (
<p className="text-sm text-muted-foreground">Hinweise werden geladen …</p>
) : null}
{enabled && isError ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-warning/20 px-3 py-2 text-sm">
<span>Hinweise (z. B. Wartezeit) konnten nicht geladen werden.</span>
<Button type="button" variant="outline" size="touch" onClick={onRetry} disabled={isRetrying}>
Erneut versuchen
</Button>
</div>
) : null}
</div>
);
}
+12 -1
View File
@@ -1,5 +1,10 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateExtractionInput, Extraction, UpdateExtractionInput } from "./types";
import {
CreateExtractionInput,
Extraction,
ExtractionPrefill,
UpdateExtractionInput,
} from "./types";
export function fetchExtractions(): Promise<Extraction[]> {
return apiFetch<Extraction[]>("/extractions");
@@ -9,6 +14,12 @@ export function fetchExtraction(id: string): Promise<Extraction> {
return apiFetch<Extraction>(`/extractions/${encodeURIComponent(id)}`);
}
/** Proposal from the chosen harvests (query key ["extractions", "prefill", ids]). */
export function fetchExtractionPrefill(harvestIds: string[]): Promise<ExtractionPrefill> {
const ids = harvestIds.map(encodeURIComponent).join(",");
return apiFetch<ExtractionPrefill>(`/extractions/prefill?harvestIds=${ids}`);
}
export function createExtraction(input: CreateExtractionInput): Promise<Extraction> {
return apiFetch<Extraction>("/extractions", {
method: "POST",
+11 -1
View File
@@ -1,3 +1,5 @@
import type { PlausibilityWarning, WithWarnings } from "@/lib/warnings/types";
export interface ExtractionHarvestEntry {
id: string;
harvestId: string;
@@ -11,7 +13,7 @@ export interface ExtractionHarvestEntry {
};
}
export interface Extraction {
export interface Extraction extends WithWarnings {
id: string;
date: string;
userId: string;
@@ -46,3 +48,11 @@ export interface UpdateExtractionInput {
temperature?: number | null;
notes?: string | null;
}
/** A proposal from the chosen harvests: never a measurement, never applied silently. */
export interface ExtractionPrefill {
amountKg: number | null;
honeyType: string | null;
sources: { harvestId: string; date: string; netWeight: number | null }[];
warnings: PlausibilityWarning[];
}
@@ -1,6 +1,11 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import {
BookHoneyBatchMovementInput,
CreateHoneyBatchInput,
HoneyBatchMovement,
HoneyBatchMovementPage,
HoneyBatchPrefill,
CreateHoneyMeasurementInput,
UpdateHoneyMeasurementInput,
HoneyBatch,
@@ -107,3 +112,30 @@ export function removeHoneyBatchContainerAllocation(
method: "DELETE",
});
}
export function fetchHoneyBatchPrefill(extractionId: string): Promise<HoneyBatchPrefill> {
return apiFetch<HoneyBatchPrefill>(
`/honey-batches/prefill?extractionId=${encodeURIComponent(extractionId)}`,
);
}
/** The newest bookings and whether older ones exist (the server sends 200). */
export function fetchHoneyBatchMovementPage(id: string): Promise<HoneyBatchMovementPage> {
return apiFetch<HoneyBatchMovementPage>(`/honey-batches/${encodeURIComponent(id)}/movements`);
}
export async function fetchHoneyBatchMovements(id: string): Promise<HoneyBatchMovement[]> {
return (await fetchHoneyBatchMovementPage(id)).items;
}
export function bookHoneyBatchMovement(
id: string,
input: BookHoneyBatchMovementInput,
options: CreateOptions,
): Promise<HoneyBatchMovement> {
return apiFetch<HoneyBatchMovement>(`/honey-batches/${encodeURIComponent(id)}/movements`, {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
@@ -0,0 +1,37 @@
import { ApiError } from "@/lib/auth/api-client";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { issuesByField } from "@/lib/automation/rule-configs";
const OFFLINE_TEXT = "Keine Verbindung – nichts gespeichert.";
const INVALID_TEXT = "Die Eingabe ist ungültig.";
export interface SaveError {
/** A text for the whole form (no field named). */
general: string | null;
/** Server messages per field (400 `issues[]`). */
fields: Record<string, string>;
}
/**
* The ONE place that reads a failed honey save: 400 `issues[]` per field,
* Nest's English `message[]` replaced, a service message (409) as it is.
* A 409 `CONFIRMATION_REQUIRED` (water content) is to be recognised here
* (`status === 409` and `body.code`) once that flow exists.
*/
export function describeSaveError(error: unknown, fallback: string): SaveError {
if (isNetworkError(error)) return { general: OFFLINE_TEXT, fields: {} };
if (!(error instanceof ApiError)) return { general: fallback, fields: {} };
const fields = issuesByField(error);
if (Object.keys(fields).length > 0) return { general: null, fields };
if (error.status === 400) {
const message = (error.body as { message?: unknown } | null)?.message;
return { general: typeof message === "string" && message ? message : INVALID_TEXT, fields: {} };
}
return { general: error.message || fallback, fields: {} };
}
/** One line for forms without field slots (the booking dialogs). */
export function saveErrorText(error: unknown, fallback: string): string {
const { general, fields } = describeSaveError(error, fallback);
return general ?? Object.values(fields).join(" ");
}
@@ -0,0 +1,43 @@
import { WITHDRAWAL_REASON_LABELS } from "@/lib/stock/labels";
import type { HoneyBatchMovement, HoneyBatchMovementType } from "./types";
const GRAMS_PER_KG = 1000;
const MINUS = "−";
const KG_FORMAT = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
export const HONEY_MOVEMENT_TYPE_LABELS: Record<HoneyBatchMovementType, string> = {
BOTTLING: "Abfüllung",
WITHDRAWAL: "Entnahme",
CORRECTION: "Korrektur",
SHOP_SALE: "Webshop-Verkauf",
SHOP_RETURN: "Webshop-Rückbuchung",
CONSUMPTION: "Verbrauch in Herstellung",
};
export function gramsToKg(grams: number): number {
return grams / GRAMS_PER_KG;
}
/** "12,5 kg" - German decimal comma, gram precision. */
export function formatKg(kg: number): string {
return `${KG_FORMAT.format(Math.round(kg * GRAMS_PER_KG) / GRAMS_PER_KG)} kg`;
}
/** "+2 kg" / "−12,5 kg": the sign is always written, a real minus sign. */
export function formatSignedKg(kg: number): string {
const rounded = Math.round(kg * GRAMS_PER_KG) / GRAMS_PER_KG;
const sign = rounded < 0 ? MINUS : "+";
return `${sign}${formatKg(Math.abs(rounded))}`;
}
/** Title of one booking, e.g. "Entnahme · Probe / Muster". */
export function movementTitle(movement: HoneyBatchMovement): string {
const label = HONEY_MOVEMENT_TYPE_LABELS[movement.type];
return movement.reason ? `${label} · ${WITHDRAWAL_REASON_LABELS[movement.reason]}` : label;
}
/** Difference between the weighed amount and the booked one, on the gram grid. */
export function differenceKg(weighedKg: number, bookedKg: number): number {
return Math.round((weighedKg - bookedKg) * GRAMS_PER_KG) / GRAMS_PER_KG;
}
@@ -1,3 +1,6 @@
import type { PlausibilityWarning, WithWarnings } from "@/lib/warnings/types";
import type { WithdrawalReason } from "@/lib/stock/types";
export type HoneyBatchStatus =
| "RAW"
| "EXTRACTED"
@@ -23,7 +26,7 @@ export interface StorageContainerRef {
location: string | null;
}
export interface HoneyBatch {
export interface HoneyBatch extends WithWarnings {
id: string;
batchNumber: string;
extractionId: string;
@@ -120,3 +123,45 @@ export interface UpdateHoneyMeasurementInput {
temperature?: number | null;
notes?: string | null;
}
/** A proposal for the new batch from its extraction: never a measurement. */
export interface HoneyBatchPrefill {
remainingKg: number | null;
honeyType: string | null;
waterContent: number | null;
warnings: PlausibilityWarning[];
}
export type HoneyBatchMovementType =
| "BOTTLING"
| "WITHDRAWAL"
| "CORRECTION"
| "SHOP_SALE"
| "SHOP_RETURN"
| "CONSUMPTION";
/** One booking in the movement book; the quantity is signed grams. */
export interface HoneyBatchMovement {
id: string;
type: HoneyBatchMovementType;
quantityGrams: number;
reason: WithdrawalReason | null;
notes: string | null;
sourceBottlingBatchId: string | null;
createdAt: string;
userId: string | null;
}
/** The newest bookings; `hasMore` when older ones are not sent. */
export interface HoneyBatchMovementPage {
items: HoneyBatchMovement[];
hasMore: boolean;
}
/** A manual booking. deltaKg is signed and never 0 (a withdrawal is negative). */
export interface BookHoneyBatchMovementInput {
type: "WITHDRAWAL" | "CORRECTION";
deltaKg: number;
reason?: WithdrawalReason;
notes?: string;
}
+105 -2
View File
@@ -63,12 +63,114 @@ async function createVersion2Queue(entry: object): Promise<void> {
db.close();
}
/** A device that last opened the app with DB v3 (before plant reports). */
async function createVersion3Queue(inspection: object): Promise<void> {
const db = await openDB(DB_NAME, 3, {
upgrade(database) {
for (const store of [...VERSION_2_STORES, "pending-bulk"]) {
database.createObjectStore(store, {
keyPath: "id",
autoIncrement: true,
});
}
const photos = database.createObjectStore("pending-photos", {
keyPath: "id",
});
photos.createIndex("sessionId", "sessionId");
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
photos.createIndex("ownerUserId", "ownerUserId");
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
database.createObjectStore("meta");
},
});
await db.add("pending-inspections", inspection);
db.close();
}
describe("offline queue database", () => {
beforeEach(() => {
globalThis.indexedDB = new IDBFactory();
});
it("upgrading v2 → v3 keeps already queued inspections", async () => {
it.each([
["a fresh install (0)", 0],
["version 1", 1],
])("opening from %s creates the plant report store at version 4", async (_name, from) => {
if (from === 1) {
const old = await openDB(DB_NAME, 1, {
upgrade(database) {
database.createObjectStore("pending-inspections", {
keyPath: "id",
autoIncrement: true,
});
},
});
await old.add("pending-inspections", {
input: INSPECTION,
queuedAt: "2026-10-01T08:00:00.000Z",
});
old.close();
}
const db = await loadDb();
await db.queuePhenologyReport(
{ phase: "APFEL", date: "2026-04-20" },
"plant-key-0000000003",
"user-1",
);
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
upgraded.close();
});
it("db upgrade 3 -> 4 keeps queued inspections and adds the plant report queue", async () => {
await createVersion3Queue({
input: INSPECTION,
idempotencyKey: "key-from-v3",
ownerUserId: "user-1",
queuedAt: "2026-10-01T08:00:00.000Z",
});
const db = await loadDb();
const entries = await db.getPendingEntries("pending-inspections");
expect(entries).toEqual([
{
id: 1,
input: INSPECTION,
idempotencyKey: "key-from-v3",
ownerUserId: "user-1",
queuedAt: "2026-10-01T08:00:00.000Z",
},
]);
await db.queuePhenologyReport(
{ phase: "SALWEIDE", date: "2026-03-20", apiaryId: null },
"key-plant-0000000001",
"user-1",
);
expect(await db.getPendingEntries("pending-phenology-reports")).toEqual([
expect.objectContaining({
input: { phase: "SALWEIDE", date: "2026-03-20", apiaryId: null },
idempotencyKey: "key-plant-0000000001",
ownerUserId: "user-1",
}),
]);
expect(await db.summarizeQueue("user-1")).toEqual({
own: 2,
failed: 0,
foreign: 0,
});
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(4);
upgraded.close();
});
it("upgrading v2 → v4 keeps already queued inspections", async () => {
await createVersion2Queue({
input: INSPECTION,
idempotencyKey: "key-from-v2",
@@ -90,7 +192,7 @@ describe("offline queue database", () => {
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(3);
expect(upgraded.version).toBe(4);
expect([...upgraded.objectStoreNames]).toEqual(
expect.arrayContaining([
...VERSION_2_STORES,
@@ -98,6 +200,7 @@ describe("offline queue database", () => {
"pending-photos",
"offline-packs",
"meta",
"pending-phenology-reports",
]),
);
expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([
+21 -2
View File
@@ -7,6 +7,7 @@ import { CreateFeedingInput } from "@/lib/feedings/types";
import { CreateVarroaMeasurementInput } from "@/lib/varroa/types";
import { CreateSwarmControlCheckInput } from "@/lib/swarm-control/types";
import { CreateHarvestInput } from "@/lib/harvests/types";
import type { CreatePhenologyReportInput } from "@/lib/phenology/types";
import {
colonyIdsOfBulkInput,
invalidColonyIdsOfBody,
@@ -21,6 +22,7 @@ export interface RecordQueueInputs {
"pending-varroa-measurements": CreateVarroaMeasurementInput;
"pending-swarm-control-checks": CreateSwarmControlCheckInput;
"pending-harvests": CreateHarvestInput;
"pending-phenology-reports": CreatePhenologyReportInput;
}
export type RecordStoreName = keyof RecordQueueInputs;
/** Every store whose entries wait to be sent and are shown in the queue sheet. */
@@ -107,11 +109,12 @@ export interface OfflineDB extends DBSchema, RecordStores {
const DB_NAME = "apicore-offline";
// v1 only had "pending-inspections", v2 added the other five quick-capture
// types, v3 adds bulk entries, photos, offline packs and meta. Entries already
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
// reports (Pflanzenentwicklung). Entries already
// queued on a phone must survive every upgrade, so each store is only created
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
// convention), never recreated.
const DB_VERSION = 3;
const DB_VERSION = 4;
const RECORD_STORES: readonly RecordStoreName[] = [
"pending-inspections",
@@ -120,6 +123,7 @@ const RECORD_STORES: readonly RecordStoreName[] = [
"pending-varroa-measurements",
"pending-swarm-control-checks",
"pending-harvests",
"pending-phenology-reports",
];
const QUEUE_STORES: readonly QueueStoreName[] = [...RECORD_STORES, "pending-bulk"];
@@ -130,6 +134,7 @@ const RECORD_LABELS: Record<RecordStoreName, string> = {
"pending-varroa-measurements": "Varroamessung",
"pending-swarm-control-checks": "Schwarmkontrolle",
"pending-harvests": "Ernte",
"pending-phenology-reports": "Pflanzenmeldung",
};
const BULK_LABELS: Record<BulkKind, string> = {
treatments: "Behandlung für mehrere Völker",
@@ -172,6 +177,12 @@ export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
db.createObjectStore("offline-packs", { keyPath: "apiaryId" });
db.createObjectStore("meta");
}
if (oldVersion < 4) {
db.createObjectStore("pending-phenology-reports", {
keyPath: "id",
autoIncrement: true,
});
}
},
// A newer app version in another tab wants to upgrade: step aside
// instead of blocking it. The next call here opens the new version.
@@ -263,6 +274,14 @@ export function queueHarvest(
return queue("pending-harvests", input, idempotencyKey, ownerUserId);
}
export function queuePhenologyReport(
input: CreatePhenologyReportInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queue("pending-phenology-reports", input, idempotencyKey, ownerUserId);
}
/**
* One entry for several colonies. The key belongs to this exact selection of
* colonies: it is stored with the entry and sent again unchanged.
@@ -7,6 +7,7 @@ import { readHandOvers, rememberHandOver } from "@/lib/photos/handover";
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() }));
vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() }));
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
// What the inspection drain hands over to the photos, seen at that moment.
@@ -45,6 +46,9 @@ let photos: PhotoStoreModule;
let ApiError: ApiErrorClass;
let createInspection: MockedFunction<typeof import("@/lib/inspections/api").createInspection>;
let createTreatment: MockedFunction<typeof import("@/lib/treatments/api").createTreatment>;
let createPhenologyReport: MockedFunction<
typeof import("@/lib/phenology/api").createPhenologyReport
>;
let uploadDocument: MockedFunction<typeof import("@/lib/documents/api").uploadDocument>;
let refreshPacksAfterSync: MockedFunction<
typeof import("@/lib/apiaries/offline-pack").refreshPacksAfterSync
@@ -95,6 +99,9 @@ describe("syncPendingEntries", () => {
createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection);
createTreatment = vi.mocked((await import("@/lib/treatments/api")).createTreatment);
uploadDocument = vi.mocked((await import("@/lib/documents/api")).uploadDocument);
createPhenologyReport = vi.mocked(
(await import("@/lib/phenology/api")).createPhenologyReport,
);
refreshPacksAfterSync = vi.mocked(
(await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync,
);
@@ -102,6 +109,7 @@ describe("syncPendingEntries", () => {
createInspection.mockReset();
createTreatment.mockReset();
uploadDocument.mockReset();
createPhenologyReport.mockReset();
refreshPacksAfterSync.mockReset().mockResolvedValue(undefined);
photoHandOver.calls.length = 0;
photoHandOver.failFor = null;
@@ -122,6 +130,55 @@ describe("syncPendingEntries", () => {
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
});
it("offline the plant report is queued and synced later with its key", async () => {
const report = {
phase: "SALWEIDE",
date: "2026-03-20",
apiaryId: null,
} as const;
await db.queuePhenologyReport(report, "plant-key-0000000001", "user-1");
createPhenologyReport.mockResolvedValue({ id: "r-1" } as never);
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 });
expect(createPhenologyReport).toHaveBeenCalledWith(report, {
idempotencyKey: "plant-key-0000000001",
});
expect(await db.getPendingEntries("pending-phenology-reports")).toEqual([]);
});
it("skips a plant report of another account", async () => {
await db.queuePhenologyReport(
{ phase: "SALWEIDE", date: "2026-03-20" },
"plant-key-0000000004",
"user-2",
);
const result = await sync.syncPendingEntries("user-1");
expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 });
expect(createPhenologyReport).not.toHaveBeenCalled();
expect(await db.getPendingEntries("pending-phenology-reports")).toHaveLength(1);
});
it("keeps a plant report the server rejected, with the reason", async () => {
await db.queuePhenologyReport(
{ phase: "KIRSCHE", date: "2026-04-02" },
"plant-key-0000000002",
"user-1",
);
createPhenologyReport.mockRejectedValue(
new ApiError(400, "Das Datum liegt in der Zukunft.", {}),
);
const result = await sync.syncPendingEntries("user-1");
expect(result.failed).toBe(1);
const [entry] = await db.getPendingEntries("pending-phenology-reports");
expect(entry.lastError?.message).toBe("Das Datum liegt in der Zukunft.");
});
it("resends a queued entry with the idempotency key it was queued with", async () => {
// The server may already have saved the first attempt; the same key lets
// it answer with that record instead of storing a duplicate.
+3 -1
View File
@@ -6,6 +6,7 @@ import { createFeeding } from "@/lib/feedings/api";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
import { createHarvest } from "@/lib/harvests/api";
import { createPhenologyReport } from "@/lib/phenology/api";
import { uploadDocument } from "@/lib/documents/api";
import {
createFeedingsBulk,
@@ -80,7 +81,7 @@ let generation = 0;
let isOpen = true;
/**
* Drains all six offline queues in one pass (not just inspections) - a
* Drains all seven offline queues in one pass (not just inspections) - a
* beekeeper working a row of hives with no signal may have queued several
* different record types before connectivity returns. Only entries of
* `currentUserId` (and ownerless ones from before owners existed) are sent.
@@ -336,6 +337,7 @@ async function runSync(run: SyncRun): Promise<SyncResult> {
drain("pending-varroa-measurements", createVarroaMeasurement, run),
drain("pending-swarm-control-checks", createSwarmControlCheck, run),
drain("pending-harvests", createHarvest, run),
drain("pending-phenology-reports", createPhenologyReport, run),
drainBulk(run),
]);
// Photos last: the drains above have just given them their inspection's
+48
View File
@@ -0,0 +1,48 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import { queuePhenologyReport as queueInOfflineDb } from "@/lib/offline/db";
import type { CreatePhenologyReportInput, PhenologyReport, PhenologyState } from "./types";
export const phenologyStateKey = ["phenology", "state"] as const;
export function phenologyReportsKey(year: number) {
return ["phenology", "reports", year] as const;
}
export function fetchPhenologyState(): Promise<PhenologyState[]> {
return apiFetch<PhenologyState[]>("/phenology/state");
}
export function fetchPhenologyReports(year: number): Promise<PhenologyReport[]> {
return apiFetch<PhenologyReport[]>(`/phenology/reports?year=${encodeURIComponent(year)}`);
}
export function createPhenologyReport(
input: CreatePhenologyReportInput,
options: CreateOptions = {},
): Promise<PhenologyReport> {
return apiFetch<PhenologyReport>("/phenology/reports", {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
export function revokePhenologyReport(
id: string,
options: CreateOptions = {},
): Promise<PhenologyReport> {
return apiFetch<PhenologyReport>(`/phenology/reports/${encodeURIComponent(id)}/revoke`, {
method: "POST",
headers: idempotencyHeaders(options),
});
}
/** Without a connection the report waits in the offline queue until it can be sent. */
export function queuePhenologyReport(
input: CreatePhenologyReportInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
return queueInOfflineDb(input, idempotencyKey, ownerUserId);
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { PHASE_REPORT_LABELS, SEASON_NOTE_LABELS, PHASE_SOURCE_LABELS, phaseSourceText } from "./labels";
import { PHENOLOGY_PHASES } from "./types";
describe("phenology labels", () => {
it("names every phase the way a beekeeper says it", () => {
expect(PHENOLOGY_PHASES.map((phase) => PHASE_REPORT_LABELS[phase])).toEqual([
"Salweide blüht",
"Kirsche blüht",
"Apfel blüht",
"Holunder blüht",
"Linde blüht",
"Tracht vorbei",
"Efeu blüht",
"Winterruhe",
]);
});
it("names the three sources", () => {
expect(PHASE_SOURCE_LABELS).toEqual({
REPORT: "gemeldet",
GTS: "geschätzt",
CALENDAR: "Kalender",
});
});
it("explains each weak basis in words", () => {
expect(SEASON_NOTE_LABELS).toEqual({
NO_COORDINATES: "Keine Koordinaten",
WEATHER_GAPS: "Wetterdaten lückenhaft",
WEATHER_STALE: "Wetterdaten veraltet",
});
});
it("adds the report day only for reported phases", () => {
expect(phaseSourceText("REPORT", "2026-04-18")).toBe("gemeldet am 18.04.");
expect(phaseSourceText("REPORT", null)).toBe("gemeldet");
expect(phaseSourceText("GTS", "2026-04-18")).toBe("geschätzt");
expect(phaseSourceText("CALENDAR", null)).toBe("Kalender");
expect(phaseSourceText(null, null)).toBeNull();
});
});
@@ -0,0 +1,36 @@
import { formatDayMonth } from "@/lib/autopilot/format";
import type { PhaseSource, PhenologyPhase, SeasonNote } from "./types";
export const PHASE_REPORT_LABELS: Record<PhenologyPhase, string> = {
SALWEIDE: "Salweide blüht",
KIRSCHE: "Kirsche blüht",
APFEL: "Apfel blüht",
HOLUNDER: "Holunder blüht",
LINDE: "Linde blüht",
TRACHTENDE: "Tracht vorbei",
HERBST: "Efeu blüht",
WINTER: "Winterruhe",
};
export const PHASE_SOURCE_LABELS: Record<PhaseSource, string> = {
REPORT: "gemeldet",
GTS: "geschätzt",
CALENDAR: "Kalender",
};
/** Why the estimate is weak, when it is. */
export const SEASON_NOTE_LABELS: Record<NonNullable<SeasonNote>, string> = {
NO_COORDINATES: "Keine Koordinaten",
WEATHER_GAPS: "Wetterdaten lückenhaft",
WEATHER_STALE: "Wetterdaten veraltet",
};
/** „gemeldet am 18.04.“, „geschätzt“, „Kalender“; null without a known source. */
export function phaseSourceText(
source: PhaseSource | null,
reportedOn: string | null,
): string | null {
if (!source) return null;
const label = PHASE_SOURCE_LABELS[source];
return source === "REPORT" && reportedOn ? `${label} am ${formatDayMonth(reportedOn)}` : label;
}
@@ -0,0 +1,58 @@
import type { SeasonSegment } from "@/lib/autopilot/types";
// Mirrors ApiCore-Backend/src/phenology (phenology.service.ts, dto/) and
// src/season/season-types.ts - keep both in step.
export const PHENOLOGY_PHASES = [
"SALWEIDE",
"KIRSCHE",
"APFEL",
"HOLUNDER",
"LINDE",
"TRACHTENDE",
"HERBST",
"WINTER",
] as const;
export type PhenologyPhase = (typeof PHENOLOGY_PHASES)[number];
export type PhaseSource = "REPORT" | "GTS" | "CALENDAR";
export type SeasonNote = "NO_COORDINATES" | "WEATHER_GAPS" | "WEATHER_STALE" | null;
export interface CreatePhenologyReportInput {
phase: PhenologyPhase;
/** YYYY-MM-DD, not after today (Europe/Berlin). */
date: string;
/** null or omitted = the report counts for all apiaries. */
apiaryId?: string | null;
notes?: string | null;
}
export interface PhenologyReport {
id: string;
apiaryId: string | null;
/** null = the report counts for all apiaries. */
apiaryName: string | null;
phase: PhenologyPhase;
/** YYYY-MM-DD */
date: string;
notes: string | null;
userId: string;
userName: string;
revokedAt: string | null;
createdAt: string;
}
/** The season state of an apiary; the API never sends coordinates. */
export interface PhenologyState {
apiaryId: string;
apiaryName: string;
phase: PhenologyPhase | null;
segment: SeasonSegment;
phaseSource: PhaseSource | null;
/** YYYY-MM-DD; only set when the phase comes from a report. */
reportedOn: string | null;
gts: number | null;
gtsThroughDay: string | null;
note: SeasonNote;
}