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:
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>
|
||||
);
|
||||
}
|
||||
+147
@@ -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>;
|
||||
}
|
||||
+91
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user