fix: T4 browser-check findings, interval on season state, T6 plan refresh

- Honey batch create omits an empty batch number (was 400); product form
  labels the legacy total weight, detail shows jar weight and shelf life;
  bottling hints next to their fields, values cleared on product change,
  post-save water-content wording; withdraw reason placeholder not
  selectable; honey batch actions offline-disabled with reason, 44 px.
- Offline signal: one slow request no longer flips the app offline; two
  consecutive timeouts do, any HTTP answer clears it, aborts never count.
- Phenology sheet as a side card on desktop with focus return; confirm
  dialogs as alertdialog; step-up texts in du-form ("Das Passwort stimmt
  nicht."); backup import gives its disabled reason; missing event labels
  plus a guard test; profile buttons 44 px.
- GET /phenology/state returns intervalDays via the shared
  inspectionIntervalOf (autopilot, digest, season state use one source).
- T6 plan aligned with the code after T4, plus owner decisions of
  2026-10-15 (Berlin day for restrictions, COMPLIANCE category, override
  without step-up).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 12:47:08 +02:00
1 parent 9760ce154f
commit 7a1480138d
63 files changed
+1076 -251

No files matched your search

@@ -0,0 +1,17 @@
import { Info } from "lucide-react";
/**
* "Weicht vom Artikel ab", right under the field it is about. Plain text the
* field points at with aria-describedby - not a live region, the one live
* region of the form stays at the top.
*/
export function DifferenceHint({ id, message }: { id: string; message: string | null }) {
if (!message) return null;
return (
<p id={id} className="flex items-start gap-2 text-sm">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
<span className="font-medium">Hinweis</span>
<span>{message}</span>
</p>
);
}
@@ -276,6 +276,53 @@ describe("NewBottlingBatchPage", () => {
expect(screen.getByText("Hinweis")).toBeInTheDocument();
});
it("shows the differs hint under its own field, outside every live region", async () => {
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "500");
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
target: { value: "2029-01-01" },
});
const jarHint = screen.getByText("Glasgröße weicht vom Artikel ab (Artikel: 250 g).");
const bbdHint = screen.getByText("MHD weicht vom Artikel ab (Artikel: 14.10.2028).");
expect(screen.getByLabelText(/Glasgröße/).getAttribute("aria-describedby")).toContain(
jarHint.closest("p")!.id,
);
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/).getAttribute("aria-describedby")).toContain(
bbdHint.closest("p")!.id,
);
expect(jarHint.closest("[aria-live]")).toBeNull();
expect(bbdHint.closest("[aria-live]")).toBeNull();
expect(screen.getAllByText(/weicht vom Artikel ab/)).toHaveLength(2);
});
it("clears jar size and best before typed for the previous product when the new one has no values", async () => {
vi.mocked(fetchProducts).mockResolvedValue([
{ id: "p-1", name: "Frühtracht 250 g", kind: "BOTTLED_HONEY" },
{ id: "p-3", name: "Honig ohne Angaben", kind: "BOTTLED_HONEY" },
] as never);
renderPage();
await chooseProduct();
await waitFor(() => expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(250));
const jarSize = screen.getByLabelText(/Glasgröße/);
await userEvent.clear(jarSize);
await userEvent.type(jarSize, "300");
fireEvent.change(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), {
target: { value: "2027-01-31" },
});
await chooseProduct("Honig ohne Angaben");
await screen.findByText(/Am Artikel ist keine Haltbarkeit hinterlegt/);
expect(screen.getByLabelText(/Mindesthaltbarkeitsdatum/)).toHaveValue("");
expect(screen.getByLabelText(/Glasgröße/)).toHaveValue(500);
expect(screen.queryByText(/weicht vom Artikel ab/)).not.toBeInTheDocument();
});
it("fills jar size and best before from the product and names the source", async () => {
renderPage();
await chooseProduct();
@@ -343,7 +390,7 @@ describe("NewBottlingBatchPage", () => {
renderPage();
await fillAndSubmit();
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent(
"Wassergehalt 20,5 % über der gesetzlichen Grenze. Trotzdem abfüllen?",
);
@@ -364,15 +411,39 @@ describe("NewBottlingBatchPage", () => {
await waitFor(() => expect(push).toHaveBeenCalledWith("/bottling/b-1"));
});
it("words the toast after the confirmed save as a fact, not as the pre-save text", async () => {
vi.mocked(createBottlingBatch)
.mockRejectedValueOnce(confirmationRequired(22))
.mockResolvedValueOnce({
id: "b-1",
warnings: [
{
code: "WATER_CONTENT_ABOVE_LEGAL",
severity: "BESTAETIGEN",
message: "Der Wassergehalt (22 %) liegt über der gesetzlichen Grenze von 20 %. Abfüllen nur mit Bestätigung.",
refs: [],
},
],
} as never);
renderPage();
await fillAndSubmit();
await userEvent.click(await screen.findByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
const options = vi.mocked(toast.warning).mock.calls[0][1] as { description: string };
expect(options.description).toBe("Wassergehalt 22\u00a0% über der Grenze – mit Bestätigung abgefüllt.");
expect(options.description).not.toMatch(/nur mit Bestätigung/);
});
it("cancel writes nothing, keeps the form and puts the focus back on the button", async () => {
vi.mocked(createBottlingBatch).mockRejectedValueOnce(confirmationRequired(20.5));
renderPage();
await fillAndSubmit();
await screen.findByRole("dialog");
await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Abbrechen" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument());
expect(createBottlingBatch).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText(/Anzahl Gläser/)).toHaveValue(24);
expect(push).not.toHaveBeenCalled();
@@ -388,11 +459,11 @@ describe("NewBottlingBatchPage", () => {
.mockResolvedValueOnce({ id: "b-1" } as never);
renderPage();
await fillAndSubmit();
await screen.findByRole("dialog");
await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() =>
expect(screen.getByRole("dialog")).toHaveTextContent(
expect(screen.getByRole("alertdialog")).toHaveTextContent(
"Wassergehalt 21 % über der gesetzlichen Grenze.",
),
);
@@ -524,11 +595,11 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
await userEvent.click(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
await waitFor(() => expect(dialog).toHaveTextContent("Die Anfrage läuft noch."));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
});
it("a double click on Trotzdem abfüllen sends exactly one request", async () => {
@@ -538,12 +609,12 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
await screen.findByRole("dialog");
await screen.findByRole("alertdialog");
await userEvent.dblClick(screen.getByRole("button", { name: "Trotzdem abfüllen" }));
expect(createBottlingBatch).toHaveBeenCalledTimes(2);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
});
it("Tab stays inside the dialog", async () => {
@@ -552,7 +623,7 @@ describe("NewBottlingBatchPage", () => {
await userEvent.type(screen.getByLabelText(/Anzahl Gläser/), "24");
await userEvent.type(screen.getByLabelText(/Mindesthaltbarkeitsdatum/), "2028-07-01");
await userEvent.click(screen.getByRole("button", { name: "Abfüllung anlegen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
const outside = [
screen.getByLabelText(/Anzahl Gläser/),
@@ -578,7 +649,7 @@ describe("NewBottlingBatchPage", () => {
onCancel={() => undefined}
/>,
);
expect(screen.getByRole("dialog")).toHaveTextContent("Wassergehalt 20,5 %");
expect(screen.getByRole("alertdialog")).toHaveTextContent("Wassergehalt 20,5 %");
rerender(
<WaterContentConfirmDialog
@@ -590,7 +661,7 @@ describe("NewBottlingBatchPage", () => {
onCancel={() => undefined}
/>,
);
const closing = screen.queryByRole("dialog");
const closing = screen.queryByRole("alertdialog");
if (closing) expect(closing).toHaveTextContent("Wassergehalt 20,5 %");
});
@@ -9,8 +9,8 @@ import { fetchHoneyBatches } from "@/lib/honey-batches/api";
import { fetchProducts } from "@/lib/products/api";
import { createBottlingBatch, fetchBottlingPrefill } from "@/lib/bottling/api";
import { WATER_CONTENT_ABOVE_LEGAL, type BottlingPrefill } from "@/lib/bottling/types";
import { postSaveWarnings } from "@/lib/bottling/post-save-warnings";
import { ApiError } from "@/lib/auth/api-client";
import type { PlausibilityWarning } from "@/lib/warnings/types";
import { PrefillStatus } from "@/components/warnings/prefill-status";
import { useBottlingWithdrawalHints } from "@/lib/bottling/use-withdrawal-hints";
import { useFormDraft } from "@/lib/forms/use-form-draft";
@@ -31,6 +31,7 @@ import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { WaterContentConfirmDialog } from "./water-content-confirm-dialog";
import { WaterContentNotes } from "./water-content-notes";
import { DifferenceHint } from "./difference-hint";
import {
BottlingFormValues,
initialBottlingValues,
@@ -89,6 +90,10 @@ type BottlingDraft = BottlingFormValues & { manual?: ManualEdits };
/** The prefill with the batch and product it was asked for. */
type PrefillResult = BottlingPrefill & { honeyBatchId: string; productId: string; date: string };
function joinIds(...ids: (string | undefined)[]): string | undefined {
return ids.filter(Boolean).join(" ") || undefined;
}
function germanDay(day: string): string {
return day.split("-").reverse().join(".");
}
@@ -178,14 +183,25 @@ export default function NewBottlingBatchPage() {
key: K,
value: BottlingFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
if (key === "productId") {
// A new product brings new master data; earlier typing no longer applies.
// A new product brings new master data; jar size and best before typed
// for the previous product no longer apply and must not stay next to
// the new product's text (e.g. "Am Artikel ist keine Haltbarkeit ...").
const fresh = initialBottlingValues("");
setValues((current) => ({
...current,
productId: value as string,
jarSize: fresh.jarSize,
bestBeforeDate: fresh.bestBeforeDate,
}));
setManual(NO_MANUAL_EDITS);
setFromDraft(NO_MANUAL_EDITS);
} else if (key === "jarSize" || key === "bestBeforeDate") {
setValues((current) => ({ ...current, [key]: value }));
setManual((current) => ({ ...current, [key]: true }));
setFromDraft((current) => ({ ...current, [key]: false }));
} else {
setValues((current) => ({ ...current, [key]: value }));
}
setDirty(true);
if ((REQUIRED_KEYS as string[]).includes(key)) {
@@ -207,14 +223,17 @@ export default function NewBottlingBatchPage() {
},
{ idempotencyKey: idempotencyKey.current },
),
onSuccess: (bottlingBatch) => {
onSuccess: (bottlingBatch, acknowledged) => {
draft.clear();
idempotencyKey.current = newIdempotencyKey();
lastBody.current = null;
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
if (bottlingBatch.warnings?.length) {
toastWarnings(bottlingBatch.warnings, "Abfüllung angelegt");
toastWarnings(
postSaveWarnings(bottlingBatch.warnings, acknowledged ? confirmation : null),
"Abfüllung angelegt",
);
} else {
toast.success("Abfüllung angelegt");
}
@@ -326,25 +345,27 @@ export default function NewBottlingBatchPage() {
: bestBeforeBase;
// The entered values against the product, before saving (hints, never a block).
const localNotes: PlausibilityWarning[] = [];
if (prefill && formValues.productId) {
if (prefill.jarSize != null && values.jarSize.trim() !== "" && Number(values.jarSize) !== prefill.jarSize) {
localNotes.push({
code: "JAR_SIZE_DIFFERS",
severity: "HINWEIS",
message: `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`,
refs: [],
});
}
if (prefill.bestBeforeDate && values.bestBeforeDate && values.bestBeforeDate !== prefill.bestBeforeDate) {
localNotes.push({
code: "BEST_BEFORE_DIFFERS",
severity: "HINWEIS",
message: `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`,
refs: [],
});
}
}
// Shown right under the field they are about, not in the live region.
const jarSizeDiffers =
prefill &&
formValues.productId &&
prefill.jarSize != null &&
values.jarSize.trim() !== "" &&
Number(values.jarSize) !== prefill.jarSize
? `Glasgröße weicht vom Artikel ab (Artikel: ${prefill.jarSize} g).`
: null;
const bestBeforeDiffers =
prefill &&
formValues.productId &&
prefill.bestBeforeDate &&
values.bestBeforeDate &&
values.bestBeforeDate !== prefill.bestBeforeDate
? `MHD weicht vom Artikel ab (Artikel: ${germanDay(prefill.bestBeforeDate)}).`
: null;
const shownAtField = [
jarSizeDiffers ? "JAR_SIZE_DIFFERS" : null,
bestBeforeDiffers ? "BEST_BEFORE_DIFFERS" : null,
].filter((code): code is string => code !== null);
const prefillLoading = prefillQuery.isFetching;
const submitDescribedBy =
@@ -420,7 +441,7 @@ export default function NewBottlingBatchPage() {
/>
)}
</Field>
<WaterContentNotes prefill={prefill} localNotes={localNotes} />
<WaterContentNotes prefill={prefill} shownAtFieldCodes={shownAtField} />
<PrefillStatus
enabled={prefillEnabled}
isLoading={prefillQuery.isLoading}
@@ -487,19 +508,22 @@ export default function NewBottlingBatchPage() {
hint={jarSizeHint}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="jarSize"
type="number"
inputMode="numeric"
min={1}
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.jarSize}
onChange={(event) => set("jarSize", event.target.value)}
/>
<>
<Input
id={id}
data-field="jarSize"
type="number"
inputMode="numeric"
min={1}
required
className="h-11"
aria-describedby={joinIds(describedBy, jarSizeDiffers ? `${id}-differs` : undefined)}
aria-invalid={invalid}
value={values.jarSize}
onChange={(event) => set("jarSize", event.target.value)}
/>
<DifferenceHint id={`${id}-differs`} message={jarSizeDiffers} />
</>
)}
</Field>
<Field
@@ -531,19 +555,22 @@ export default function NewBottlingBatchPage() {
hint={bestBeforeHint}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
data-field="bestBeforeDate"
type="date"
required
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={values.bestBeforeDate}
onChange={(event) =>
set("bestBeforeDate", event.target.value)
}
/>
<>
<Input
id={id}
data-field="bestBeforeDate"
type="date"
required
className="h-11"
aria-describedby={joinIds(describedBy, bestBeforeDiffers ? `${id}-differs` : undefined)}
aria-invalid={invalid}
value={values.bestBeforeDate}
onChange={(event) =>
set("bestBeforeDate", event.target.value)
}
/>
<DifferenceHint id={`${id}-differs`} message={bestBeforeDiffers} />
</>
)}
</Field>
</FormSection>
@@ -47,7 +47,7 @@ export function WaterContentConfirmDialog({
if (!open && !isPending) onCancel();
}}
>
<DialogContent finalFocus={returnFocusTo}>
<DialogContent role="alertdialog" finalFocus={returnFocusTo}>
<DialogHeader>
<DialogTitle>Wassergehalt zu hoch</DialogTitle>
<DialogDescription>
@@ -2,7 +2,7 @@ import { AlertTriangle, Info, ShieldAlert } from "lucide-react";
import type { ComponentType } from "react";
import { formatWaterContent } from "@/lib/bottling/format";
import type { BottlingPrefill } from "@/lib/bottling/types";
import type { PlausibilityWarning, WarningSeverity } from "@/lib/warnings/types";
import type { WarningSeverity } from "@/lib/warnings/types";
const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentType<{ className?: string }> }> = {
HINWEIS: { label: "Hinweis", Icon: Info },
@@ -17,17 +17,18 @@ const SEVERITY_LOOK: Record<WarningSeverity, { label: string; Icon: ComponentTyp
*/
export function WaterContentNotes({
prefill,
localNotes = [],
shownAtFieldCodes = [],
}: {
prefill: BottlingPrefill | undefined;
/** Findings the form works out itself (entered value vs product), shown as hints. */
localNotes?: PlausibilityWarning[];
/**
* Codes the form shows next to their field (entered value vs product). They
* are left out here: one live region, no second announcement.
*/
shownAtFieldCodes?: string[];
}) {
const localCodes = new Set(localNotes.map((note) => note.code));
const warnings = [
...(prefill?.warnings ?? []).filter((warning) => !localCodes.has(warning.code)),
...localNotes,
];
const warnings = (prefill?.warnings ?? []).filter(
(warning) => !shownAtFieldCodes.includes(warning.code),
);
const hasContent = (prefill !== undefined && prefill.waterContent !== null) || warnings.length > 0;
return (
<div role="status" aria-live="polite">
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addHoneyMeasurement } from "@/lib/honey-batches/api";
import type { CreateHoneyMeasurementInput } from "@/lib/honey-batches/types";
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";
@@ -20,6 +21,7 @@ import { emptyToNull } from "@/lib/forms/empty-to-null";
export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string }) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [open, setOpen] = useState(false);
const [waterContent, setWaterContent] = useState("");
const [measuredAt, setMeasuredAt] = useState(todayLocalIsoDate());
@@ -56,8 +58,15 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
}
return (
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Neue Messung</Button>} />
<DialogTrigger
render={
<Button size="touch" disabled={isOffline} aria-describedby={describedBy}>
Neue Messung
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Honigmessung</DialogTitle>
@@ -107,5 +116,7 @@ export function AddMeasurementDialog({ honeyBatchId }: { honeyBatchId: string })
</form>
</DialogContent>
</Dialog>
<OnlineOnlyHint id={hintId} />
</div>
);
}
@@ -10,6 +10,7 @@ import {
} from "@/lib/honey-batches/api";
import { HoneyBatch } from "@/lib/honey-batches/types";
import { fetchStorageContainers } from "@/lib/storage/api";
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";
@@ -26,6 +27,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// represent.
export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [storageContainerId, setStorageContainerId] = useState("");
const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -167,9 +169,16 @@ export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch:
{error}
</p>
) : null}
<Button type="submit" variant="outline" disabled={assignMutation.isPending}>
<Button
type="submit"
variant="outline"
size="touch"
disabled={assignMutation.isPending || isOffline}
aria-describedby={describedBy}
>
{assignMutation.isPending ? "Wird zugewiesen…" : "Behälter zuweisen"}
</Button>
<OnlineOnlyHint id={hintId} />
</form>
</CardContent>
</Card>
@@ -10,6 +10,7 @@ import { HoneyBatch, HoneyBatchStatus } from "@/lib/honey-batches/types";
import { HONEY_BATCH_STATUS_LABELS } from "@/lib/honey-batches/labels";
import { fetchExtractions } from "@/lib/extractions/api";
import { fetchStorageContainers } from "@/lib/storage/api";
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";
@@ -38,6 +39,8 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
const [amount, setAmount] = useState(honeyBatch.amount?.toString() ?? "");
const [waterContent, setWaterContent] = useState(honeyBatch.waterContent?.toString() ?? "");
const { isOffline, hintId, describedBy } = useOnlineOnly();
const { data: extractions } = useQuery({
queryKey: ["extractions"],
queryFn: () => fetchExtractions(),
@@ -72,10 +75,11 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
}
return (
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Button variant="ghost" size="touch" disabled={isOffline} aria-describedby={describedBy}>
<Pencil className="size-3.5" />
Bearbeiten
</Button>
@@ -174,5 +178,7 @@ export function HoneyBatchEditDialog({ honeyBatch }: { honeyBatch: HoneyBatch })
</form>
</DialogContent>
</Dialog>
<OnlineOnlyHint id={hintId} />
</div>
);
}
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchHoneyBatchRelocations, relocateHoneyBatch } from "@/lib/honey-batches/api";
import { HoneyBatch, RelocateHoneyBatchInput } from "@/lib/honey-batches/types";
import { fetchStorageContainers } from "@/lib/storage/api";
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";
@@ -25,6 +26,7 @@ import { emptyToNull } from "@/lib/forms/empty-to-null";
export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
const queryClient = useQueryClient();
const { isOffline, hintId, describedBy } = useOnlineOnly();
const [open, setOpen] = useState(false);
const [storageContainerId, setStorageContainerId] = useState("");
const [notes, setNotes] = useState("");
@@ -72,8 +74,15 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Lagerbehälter</CardTitle>
<div className="flex flex-col items-end gap-1">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="sm">Umlagern</Button>} />
<DialogTrigger
render={
<Button size="touch" disabled={isOffline} aria-describedby={describedBy}>
Umlagern
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Honigcharge umlagern</DialogTitle>
@@ -111,6 +120,8 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch }
</form>
</DialogContent>
</Dialog>
<OnlineOnlyHint id={hintId} />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p>
@@ -37,6 +37,16 @@ describe("HoneyBatchWithdrawDialog", () => {
expect(screen.getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
});
it("lists the reasons but not the placeholder „Grund wählen“ as a choice", async () => {
renderDialog();
await open();
await userEvent.click(screen.getByRole("combobox"));
const placeholder = await screen.findByRole("option", { name: "Grund wählen" });
expect(placeholder).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "Hofladen" })).not.toHaveAttribute("aria-disabled", "true");
});
it("refuses more than the rest and less than a gram in words", async () => {
renderDialog();
const quantity = await open();
@@ -147,6 +147,7 @@ export function HoneyBatchWithdrawDialog({
value={reason}
onChange={setReason}
noneLabel="Grund wählen"
noneIsPlaceholder
labels={WITHDRAWAL_REASON_LABELS}
/>
</div>
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import type { HoneyBatch } from "@/lib/honey-batches/types";
import { HoneyBatchEditDialog } from "./honey-batch-edit-dialog";
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
import { HoneyBatchContainerAllocationsCard } from "./honey-batch-container-allocations-card";
import { AddMeasurementDialog } from "./add-measurement-dialog";
vi.mock("@/lib/honey-batches/api", () => ({
updateHoneyBatch: vi.fn(),
fetchHoneyBatchRelocations: vi.fn(async () => []),
relocateHoneyBatch: vi.fn(),
fetchHoneyBatchContainerAllocations: vi.fn(async () => []),
setHoneyBatchContainerAllocation: vi.fn(),
removeHoneyBatchContainerAllocation: vi.fn(),
addHoneyMeasurement: vi.fn(),
}));
vi.mock("@/lib/extractions/api", () => ({ fetchExtractions: vi.fn(async () => []) }));
vi.mock("@/lib/storage/api", () => ({ fetchStorageContainers: vi.fn(async () => []) }));
const HONEY_BATCH = {
id: "h1",
batchNumber: "H-1",
extractionId: "x1",
honeyType: null,
amount: 20,
waterContent: null,
storageContainerId: null,
storageContainer: null,
status: "STORED",
createdAt: "2026-01-01T00:00:00.000Z",
measurements: [],
} as HoneyBatch;
const ACTIONS: { name: string; button: RegExp; ui: () => React.ReactElement }[] = [
{ name: "Bearbeiten", button: /Bearbeiten/, ui: () => <HoneyBatchEditDialog honeyBatch={HONEY_BATCH} /> },
{ name: "Umlagern", button: /^Umlagern$/, ui: () => <HoneyBatchStorageCard honeyBatch={HONEY_BATCH} /> },
{
name: "Behälter zuweisen",
button: /Behälter zuweisen/,
ui: () => <HoneyBatchContainerAllocationsCard honeyBatch={HONEY_BATCH} />,
},
{ name: "Neue Messung", button: /Neue Messung/, ui: () => <AddMeasurementDialog honeyBatchId="h1" /> },
];
function renderUi(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
describe("online-only actions of the honey batch page", () => {
beforeEach(() => act(() => reportRequestOutcome("reached-server")));
afterEach(() => act(() => reportRequestOutcome("reached-server")));
for (const action of ACTIONS) {
it(`${action.name}: enabled and 44 px high online, no hint`, () => {
renderUi(action.ui());
const button = screen.getByRole("button", { name: action.button });
expect(button).toBeEnabled();
expect(button).toHaveClass("h-11");
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
});
it(`${action.name}: disabled offline with the visible reason „Nur mit Verbindung“`, () => {
renderUi(action.ui());
act(() => reportRequestOutcome("network-error"));
const button = screen.getByRole("button", { name: action.button });
const reason = screen.getByText("Nur mit Verbindung");
expect(button).toBeDisabled();
expect(button.getAttribute("aria-describedby")).toBe(reason.id);
expect(button).toHaveClass("h-11");
});
}
});
@@ -134,4 +134,17 @@ describe("CreateHoneyBatchDialog", () => {
expect(createHoneyBatch).toHaveBeenCalled();
expect(toastWarnings).toHaveBeenCalledWith([{ code: "X" }], "Honigcharge angelegt");
});
it("omits an empty batch number instead of sending null (the DTO rejects null)", async () => {
vi.mocked(createHoneyBatch).mockResolvedValue({ id: "b1" } as never);
renderDialog();
await openAndChooseExtraction();
await userEvent.click(screen.getByRole("button", { name: "Anlegen" }));
const payload = vi.mocked(createHoneyBatch).mock.calls[0][0];
expect(payload.extractionId).toBe("x1");
expect(payload.batchNumber).toBeUndefined();
expect(JSON.parse(JSON.stringify(payload))).not.toHaveProperty("batchNumber");
});
});
@@ -86,8 +86,9 @@ export function CreateHoneyBatchDialog() {
return;
}
mutation.mutate({
// Empty: the server generates the number (automation format).
batchNumber: emptyToNull(batchNumber),
// Empty: omitted, the server generates the number (automation format).
// The create DTO is IsOptionalNotNull, so null would be a 400.
batchNumber: emptyToNull(batchNumber) ?? undefined,
extractionId,
honeyType: emptyToNull(honeyType),
amount: emptyToNull(amount, Number),
@@ -93,4 +93,32 @@ describe("ProductDetailPage", () => {
const link = (await screen.findByText("K-1")).closest("a");
expect(link).toHaveAttribute("href", "/production-batches/b1");
});
it("shows the jar weight and the shelf life in the details when they are set", async () => {
vi.mocked(fetchProduct).mockResolvedValue({
id: "candle",
name: "Honig 250",
description: null,
sku: null,
packagingSize: 250,
shelfLifeMonths: 18,
weight: null,
salesUnit: null,
status: "ACTIVE",
kind: "BOTTLED_HONEY",
shopCategory: "HONIG",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
renderPage();
expect(await screen.findByText("Gewicht pro Glas: 250 g")).toBeInTheDocument();
expect(screen.getByText("Haltbarkeit: 18 Monate")).toBeInTheDocument();
});
it("hides jar weight and shelf life when they are not set", async () => {
renderPage();
await screen.findByTestId("total-remaining");
expect(screen.queryByText(/Gewicht pro Glas/)).not.toBeInTheDocument();
expect(screen.queryByText(/Haltbarkeit/)).not.toBeInTheDocument();
});
});
@@ -79,6 +79,12 @@ export default function ProductDetailPage() {
<p>
Kategorie im Webshop: {SHOP_CATEGORY_LABELS[product.shopCategory]}
</p>
{product.packagingSize != null ? (
<p>Gewicht pro Glas: {product.packagingSize.toLocaleString("de-DE")} g</p>
) : null}
{product.shelfLifeMonths != null ? (
<p>Haltbarkeit: {product.shelfLifeMonths} Monate</p>
) : null}
{product.sku ? <p>SKU: {product.sku}</p> : null}
{product.salesUnit ? (
<p>Verkaufseinheit: {product.salesUnit}</p>
@@ -36,6 +36,13 @@ async function openDialog(product: Product = PRODUCT) {
}
describe("EditProductDialog", () => {
it("labels the second weight field so it cannot be mistaken for the jar weight", async () => {
await openDialog();
expect(screen.getByLabelText(/Gewicht pro Glas \(g\)/)).toBeInTheDocument();
expect(screen.getByLabelText(/Artikelgewicht gesamt \(g\)/)).toBeInTheDocument();
expect(screen.queryByLabelText(/^Gewicht$/)).not.toBeInTheDocument();
});
beforeEach(() => {
vi.mocked(updateProduct).mockReset();
vi.mocked(updateProduct).mockResolvedValue(PRODUCT);
@@ -205,15 +205,20 @@ export function EditProductDialog({ product }: { product: Product }) {
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-weight">Gewicht</Label>
<Label htmlFor="product-edit-weight">Artikelgewicht gesamt (g)</Label>
<Input
id="product-edit-weight"
type="number"
inputMode="decimal"
step="0.01"
min={0}
aria-describedby="product-edit-weight-hint"
value={weight}
onChange={(event) => setWeight(event.target.value)}
/>
<p id="product-edit-weight-hint" className="text-xs text-muted-foreground">
Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="product-edit-status">Status</Label>
@@ -221,8 +221,8 @@ export default function NewProductPage() {
fieldCount={3}
>
<Field
label="Gewicht (g)"
hint="in Gramm"
label="Artikelgewicht gesamt (g)"
hint="Gewicht des verpackten Artikels in Gramm, getrennt vom Gewicht pro Glas"
>
{({ id, describedBy }) => (
<Input
@@ -35,6 +35,12 @@ describe("DangerZoneSection", () => {
logoutMock.mockResolvedValue(undefined);
});
it("the delete button is 44 px high", () => {
renderZone();
expect(screen.getByRole("button", { name: /Konto löschen/ })).toHaveClass("h-11");
});
it("signs out and deletes the unsent entries of a deactivated account", async () => {
vi.mocked(deactivateAccount).mockResolvedValue(undefined);
renderZone();
@@ -58,7 +58,7 @@ function DeactivateAccountDialog() {
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" variant="destructive" onClick={() => setOpen(true)}>
<Button type="button" size="touch" variant="destructive" onClick={() => setOpen(true)}>
<Trash2 className="size-4" />
Konto löschen
</Button>
@@ -88,10 +88,10 @@ function DeactivateAccountDialog() {
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen
</Button>
<Button type="submit" variant="destructive" disabled={mutation.isPending}>
<Button type="submit" size="touch" variant="destructive" disabled={mutation.isPending}>
{mutation.isPending ? "Wird deaktiviert…" : "Konto löschen"}
</Button>
</DialogFooter>
@@ -85,7 +85,7 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" onClick={handleTrigger}>
<Button type="button" size="touch" onClick={handleTrigger}>
Einrichten
</Button>
<DialogContent>
@@ -134,10 +134,10 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen
</Button>
<Button type="submit" disabled={confirmMutation.isPending}>
<Button type="submit" size="touch" disabled={confirmMutation.isPending}>
{confirmMutation.isPending ? "Prüfe…" : "Bestätigen"}
</Button>
</DialogFooter>
@@ -183,7 +183,7 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
return (
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
<Button type="button" size="touch" variant="outline" onClick={() => setOpen(true)}>
Deaktivieren
</Button>
<DialogContent>
@@ -224,10 +224,10 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
<Button type="button" size="touch" variant="outline" onClick={handleClose}>
Abbrechen
</Button>
<Button type="submit" variant="destructive" disabled={disableMutation.isPending}>
<Button type="submit" size="touch" variant="destructive" disabled={disableMutation.isPending}>
{disableMutation.isPending ? "Wird deaktiviert…" : "Deaktivieren"}
</Button>
</DialogFooter>
@@ -92,4 +92,12 @@ describe("ProfilePage", () => {
expect(vi.mocked(uploadAvatar).mock.calls[0]?.[0]).toBe(file);
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
});
it("keeps the profile buttons at 44 px (touch size)", async () => {
renderWithClient(<ProfilePage />);
for (const name of ["Bild wählen", "Speichern", "Passwort ändern"]) {
expect(await screen.findByRole("button", { name })).toHaveClass("h-11");
}
});
});
@@ -87,6 +87,7 @@ function AvatarSection() {
/>
<Button
type="button"
size="touch"
variant="outline"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
@@ -96,6 +97,7 @@ function AvatarSection() {
{user.hasAvatar ? (
<Button
type="button"
size="touch"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
@@ -205,7 +207,7 @@ function ProfileInfoSection() {
{error}
</p>
) : null}
<Button type="submit" className="self-start" disabled={mutation.isPending}>
<Button type="submit" size="touch" className="self-start" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</form>
@@ -276,7 +278,7 @@ function PasswordSection() {
{error}
</p>
) : null}
<Button type="submit" className="self-start" disabled={mutation.isPending}>
<Button type="submit" size="touch" className="self-start" disabled={mutation.isPending}>
{mutation.isPending ? "Ändert…" : "Passwort ändern"}
</Button>
</form>
@@ -72,7 +72,7 @@ function renderCard() {
async function connect(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await user.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
}
@@ -105,7 +105,7 @@ describe("TelegramLinkCard", () => {
renderCard();
await user.click(await screen.findByRole("button", { name: "Telegram verbinden" }));
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await user.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await user.type(within(dialog).getByLabelText("Code aus der Authenticator-App"), "123456");
await user.click(within(dialog).getByRole("button", { name: "Link erzeugen" }));
@@ -197,7 +197,7 @@ describe("TelegramLinkCard", () => {
renderCard();
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
expect(unlinkTelegram).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
@@ -222,7 +222,7 @@ describe("TelegramLinkCard", () => {
vi.mocked(fetchNotificationSettings).mockResolvedValue(notLinked);
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
await screen.findByRole("button", { name: "Telegram verbinden" });
@@ -235,7 +235,7 @@ describe("TelegramLinkCard", () => {
renderCard();
await user.click(await screen.findByRole("button", { name: "Trennen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
await user.click(within(dialog).getByRole("button", { name: "Trennen" }));
const connectButton = await screen.findByRole("button", { name: "Telegram verbinden" });
@@ -281,7 +281,7 @@ export function TelegramLinkCard() {
open={isStepUpOpen}
onOpenChange={setIsStepUpOpen}
title="Telegram verbinden?"
description="Zur Sicherheit fragen wir noch einmal nach Ihrem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
description="Zur Sicherheit fragen wir noch einmal nach deinem Passwort. Danach erscheint ein Link, der 10 Minuten gilt."
confirmLabel="Link erzeugen"
needsMfa={Boolean(user?.mfaEnabled)}
onConfirm={createLink}
@@ -178,17 +178,32 @@ describe("BackupPage", () => {
});
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog");
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
}
it("explains why „Backup importieren“ is off until a file is chosen", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
renderWithClient(<BackupPage />);
await screen.findByText("Aktiv");
const button = screen.getByRole("button", { name: "Backup importieren" });
expect(button).toBeDisabled();
expect(button).toHaveAccessibleDescription("Bitte zuerst eine Backup-Datei wählen.");
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
});
expect(button).toBeEnabled();
expect(screen.queryByText("Bitte zuerst eine Backup-Datei wählen.")).toBeNull();
});
it("the backup import still asks for the password through StepUpDialog", async () => {
vi.mocked(getBackupSchedule).mockResolvedValue(schedule());
vi.mocked(importBackup).mockResolvedValue({ imported: { apiary: 1 }, skipped: [], sealed: true });
renderWithClient(<BackupPage />);
expect(await screen.findByText("Aktiv")).toBeInTheDocument();
// No loose password field on the page any more.
expect(screen.queryByLabelText("Ihr Passwort")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Dein Passwort")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Backup-Datei einspielen"), {
target: { files: [new File(["{}"], "backup.json", { type: "application/json" })] },
});
@@ -196,7 +211,7 @@ describe("BackupPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Backup importieren" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByRole("button", { name: "Einspielen" })).toBeDisabled();
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "geheim");
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "geheim");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
await vi.waitFor(() =>
@@ -222,7 +237,7 @@ describe("BackupPage", () => {
expect(toast.error).not.toHaveBeenCalled();
// Still open, file still chosen: a second try needs only the password.
await userEvent.type(within(dialog).getByLabelText("Ihr Passwort"), "richtig");
await userEvent.type(within(dialog).getByLabelText("Dein Passwort"), "richtig");
await userEvent.click(within(dialog).getByRole("button", { name: "Einspielen" }));
await vi.waitFor(() => expect(importBackup).toHaveBeenCalledTimes(2));
expect(vi.mocked(importBackup).mock.calls[1]?.[0]).toBeInstanceOf(File);
@@ -165,17 +165,23 @@ function BackupSection() {
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
/>
<p className="text-muted-foreground">
Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir Ihr Passwort
Einspielen überschreibt Konten und Daten. Zur Bestätigung fragen wir dein Passwort
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""}.
</p>
<Button
type="button"
variant="outline"
disabled={!file || importMutation.isPending}
aria-describedby={file ? undefined : "backup-import-hint"}
onClick={() => setIsImportDialogOpen(true)}
>
Backup importieren
</Button>
{file ? null : (
<p id="backup-import-hint" className="text-xs text-muted-foreground">
Bitte zuerst eine Backup-Datei wählen.
</p>
)}
<StepUpDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
@@ -149,7 +149,7 @@ describe("TelegramCard", () => {
expect(toast.success).toHaveBeenCalledWith("Wird erneut versucht");
await user.click(screen.getByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }));
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
expect(dialog).toHaveTextContent("Mindestens 1 sind fehlgeschlagen.");
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");
@@ -168,7 +168,7 @@ describe("TelegramCard", () => {
await screen.findByRole("button", { name: "Alle fehlgeschlagenen erneut versuchen" }),
);
const dialog = await screen.findByRole("dialog");
const dialog = await screen.findByRole("alertdialog");
expect(dialog).toHaveTextContent("Fehlgeschlagene Nachrichten erneut senden?");
expect(dialog).toHaveTextContent("Mindestens 150 sind fehlgeschlagen.");
expect(dialog).toHaveTextContent("Es werden höchstens 100 gesendet, neueste zuerst.");