feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
import { readRound } from "@/lib/rounds/round-state";
import type { ColonyOverview, ColonyOverviewRow } from "@/lib/apiaries/field-types";
vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/field-api")>()),
fetchColonyOverview: vi.fn(),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
const apiary = { id: "a-1", name: "Obstwiese" };
function row(overrides: Partial<ColonyOverviewRow>): ColonyOverviewRow {
return {
id: "c-1",
code: "A1",
status: "ACTIVE",
strength: 6,
broodStatus: "GOOD",
feedStatus: "GOOD",
healthStatus: "GOOD",
lastInspectionDate: new Date().toISOString(),
openWarnings: 0,
openCritical: 0,
...overrides,
};
}
function overview(rows: ColonyOverviewRow[]): ColonyOverview {
return { apiaryId: "a-1", inspectionsVisible: true, rows };
}
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<StartRoundButton apiary={apiary} />
<ApiaryColoniesCard apiary={apiary} />
</QueryClientProvider>,
);
}
describe("ApiaryColoniesCard", () => {
beforeEach(() => {
window.sessionStorage.clear();
vi.mocked(fetchColonyOverview).mockReset();
});
it("lists living colonies with light and reasons", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overview([
row({ id: "c-1", code: "A1" }),
row({ id: "c-2", code: "A2", status: "QUEENLESS", openWarnings: 2 }),
]),
);
renderCard();
expect(await screen.findByRole("heading", { name: "Völker (2)" })).toBeInTheDocument();
const first = screen.getByRole("link", { name: /A1/ });
expect(first).toHaveAttribute("href", "/colonies/c-1");
expect(within(first).getByText("Gut")).toBeInTheDocument();
const second = screen.getByRole("link", { name: /A2/ });
expect(within(second).getByText("Kritisch")).toBeInTheDocument();
expect(within(second).getByText("Weisellos · 2 Warnungen")).toBeInTheDocument();
expect(within(first).getByText("Aktiv")).toBeInTheDocument();
});
it("„Runde starten\" opens the first colony's inspection with the round param", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]),
);
renderCard();
await screen.findByRole("heading", { name: "Völker (2)" });
const start = screen.getByRole("button", { name: "Runde starten" });
expect(start).toHaveAttribute("href", "/inspections/new?colonyId=c-1&round=a-1");
await userEvent.click(start);
expect(readRound("user-1")).toMatchObject({
apiaryId: "a-1",
apiaryName: "Obstwiese",
colonies: [
{ id: "c-1", code: "A1" },
{ id: "c-2", code: "A2" },
],
});
});
it("„Runde starten\" is disabled without colonies", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(overview([]));
renderCard();
expect(await screen.findByText(/Noch keine Völker/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Runde starten" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Runde starten" })).not.toHaveAttribute("href");
});
it("shows a retryable error instead of an empty list when loading fails", async () => {
vi.mocked(fetchColonyOverview).mockRejectedValue(new Error("boom"));
renderCard();
expect(await screen.findByRole("alert")).toHaveTextContent("Völker konnten nicht geladen werden");
expect(screen.getByRole("button", { name: "Runde starten" })).toBeDisabled();
});
});
@@ -0,0 +1,107 @@
"use client";
import Link from "next/link";
import { ChevronRight, Play, Sprout } from "lucide-react";
import { useColonyOverview } from "@/lib/apiaries/use-colony-overview";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels";
import { colonyStatusLight } from "@/lib/colonies/status-light";
import { todayLocalIsoDate } from "@/lib/dates";
import { startRound } from "@/lib/rounds/round-state";
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
import { EmptyState } from "@/components/empty-state";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ApiaryRef {
id: string;
name: string;
}
/** The apiary page's one main action: walk through all colonies. */
export function StartRoundButton({ apiary }: { apiary: ApiaryRef }) {
const userId = useCurrentUserId();
const { data } = useColonyOverview(apiary.id);
const rows = data?.rows ?? [];
if (rows.length === 0) {
return (
<Button size="touch" disabled>
<Play aria-hidden />
Runde starten
</Button>
);
}
return (
<Button
size="touch"
nativeButton={false}
render={
<Link
href={`/inspections/new?colonyId=${encodeURIComponent(rows[0].id)}&round=${encodeURIComponent(apiary.id)}`}
onClick={() => {
if (userId) startRound(userId, apiary, rows);
}}
/>
}
>
<Play aria-hidden />
Runde starten
</Button>
);
}
export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) {
const query = useColonyOverview(apiary.id);
const overview = query.data;
const today = todayLocalIsoDate();
return (
<Card>
<CardHeader>
<CardTitle>
<h2>Völker ({overview?.rows.length ?? 0})</h2>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{query.isLoading ? <LoadingState what="Völker" /> : null}
{!query.isLoading && !overview ? (
<ErrorState what="Völker" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null}
{overview && overview.rows.length === 0 ? (
<EmptyState
icon={Sprout}
title="Noch keine Völker an diesem Stand"
description="Sobald ein Volk hier steht, erscheint es mit seiner Ampel."
/>
) : null}
{overview?.rows.map((row) => {
const { light, reasons } = colonyStatusLight(row, {
today,
inspectionsVisible: overview.inspectionsVisible,
});
return (
<Link
key={row.id}
href={`/colonies/${row.id}`}
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{row.code}</span>
<StatusLightBadge light={light} />
<span className="text-sm text-muted-foreground">{COLONY_STATUS_LABELS[row.status]}</span>
</span>
{reasons.length > 0 ? (
<span className="text-sm text-muted-foreground">{reasons.join(" · ")}</span>
) : null}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
);
})}
</CardContent>
</Card>
);
}
@@ -20,6 +20,7 @@ const apiary: Apiary = {
latitude: 48.47,
longitude: 10.6,
address: "Hauptstr. 1",
publicLocationLabel: "Nordhessen",
status: "ACTIVE",
notes: "Schlüssel beim Nachbarn",
createdAt: "2026-01-01T00:00:00.000Z",
@@ -41,7 +42,7 @@ describe("ApiaryEditCard", () => {
</QueryClientProvider>,
);
for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen"]) {
for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen", "Ort für die Herkunftsseite"]) {
await userEvent.clear(screen.getByLabelText(new RegExp(`^${label}`)));
}
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
@@ -54,6 +55,7 @@ describe("ApiaryEditCard", () => {
longitude: null,
address: null,
notes: null,
publicLocationLabel: null,
});
});
});
@@ -14,6 +14,8 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PUBLIC_LOCATION_HINT } from "../new/apiary-form-values";
export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
const queryClient = useQueryClient();
const [name, setName] = useState(apiary.name);
@@ -23,6 +25,9 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
const [longitude, setLongitude] = useState(apiary.longitude?.toString() ?? "");
const [address, setAddress] = useState(apiary.address ?? "");
const [notes, setNotes] = useState(apiary.notes ?? "");
const [publicLocationLabel, setPublicLocationLabel] = useState(
apiary.publicLocationLabel ?? "",
);
const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -36,6 +41,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
longitude: emptyToNull(longitude, Number),
address: emptyToNull(address),
notes: emptyToNull(notes),
publicLocationLabel: emptyToNull(publicLocationLabel),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
@@ -159,6 +165,18 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
/>
)}
</Field>
<Field label="Ort für die Herkunftsseite" hint={PUBLIC_LOCATION_HINT}>
{({ id, describedBy }) => (
<Input
id={id}
className="h-11"
maxLength={100}
aria-describedby={describedBy}
value={publicLocationLabel}
onChange={(event) => setPublicLocationLabel(event.target.value)}
/>
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { FileText, MapPin, Plus } from "lucide-react";
@@ -10,7 +9,7 @@ import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { Button } from "@/components/ui/button";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
import { ApiaryEditCard } from "./apiary-edit-card";
import { WeatherCard } from "./weather-card";
@@ -51,17 +50,13 @@ export default function ApiaryDetailPage() {
label={apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
/>
}
primaryAction={
<Button
size="touch"
nativeButton={false}
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
>
<Plus aria-hidden />
Ernte erfassen
</Button>
}
primaryAction={<StartRoundButton apiary={{ id: apiary.id, name: apiary.name }} />}
moreActions={[
{
label: "Ernte erfassen",
icon: Plus,
href: `/harvests/new?apiaryId=${apiary.id}`,
},
{
label: treatmentLogMutation.isPending
? "Bestandsbuch wird erstellt…"
@@ -83,6 +78,7 @@ export default function ApiaryDetailPage() {
/>
<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 }} />
<ApiaryEditCard key={apiary.id} apiary={apiary} />
<div>
<QrCodeDialog entityType="apiaries" id={apiary.id} label="QR-Code für den Stand" />
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { initialApiaryValues, toApiaryInput } from "./apiary-form-values";
describe("toApiaryInput", () => {
it("sends a filled public location label trimmed", () => {
const input = toApiaryInput({
...initialApiaryValues(),
name: "Hausgarten",
publicLocationLabel: " Nordhessen ",
});
expect(input.publicLocationLabel).toBe("Nordhessen");
});
it("sends an empty public location label as null", () => {
const input = toApiaryInput({ ...initialApiaryValues(), name: "Hausgarten" });
expect(input.publicLocationLabel).toBeNull();
});
});
@@ -1,6 +1,10 @@
import type { ApiaryInput } from "@/lib/apiaries/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Hint of the public origin field, shared by the create form and the edit card. */
export const PUBLIC_LOCATION_HINT =
"Wird öffentlich auf der Herkunftsseite der Honiggläser gezeigt, z. B. „Nordhessen“. Nie die genaue Adresse eintragen. Leer lassen: Es erscheint nur das Land.";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ApiaryFormValues {
name: string;
@@ -9,6 +13,7 @@ export interface ApiaryFormValues {
longitude: string;
address: string;
notes: string;
publicLocationLabel: string;
}
export type ApiaryFormErrors = Partial<Record<keyof ApiaryFormValues, string>>;
@@ -21,6 +26,7 @@ export function initialApiaryValues(): ApiaryFormValues {
longitude: "",
address: "",
notes: "",
publicLocationLabel: "",
};
}
@@ -44,5 +50,6 @@ export function toApiaryInput(values: ApiaryFormValues): ApiaryInput {
longitude: emptyToNull(values.longitude, Number),
address: emptyToNull(values.address),
notes: emptyToNull(values.notes),
publicLocationLabel: emptyToNull(values.publicLocationLabel),
};
}
@@ -68,6 +68,7 @@ describe("NewApiaryPage", () => {
longitude: null,
address: "Dorfstraße 1",
notes: null,
publicLocationLabel: null,
});
expect(push).toHaveBeenCalledWith("/apiaries/apiary-3");
});
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -24,6 +25,7 @@ import {
initialApiaryValues,
toApiaryInput,
validateApiary,
PUBLIC_LOCATION_HINT,
} from "./apiary-form-values";
const PERMISSION_DENIED = 1;
@@ -120,6 +122,12 @@ export default function NewApiaryPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/apiaries"
label="Zu den Bienenständen"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={MapPin}
title="Neuer Bienenstand"
@@ -229,6 +237,19 @@ export default function NewApiaryPage() {
/>
)}
</Field>
<Field label="Ort für die Herkunftsseite" hint={PUBLIC_LOCATION_HINT}>
{({ id, describedBy }) => (
<Input
id={id}
maxLength={100}
aria-describedby={describedBy}
value={values.publicLocationLabel}
onChange={(event) =>
set("publicLocationLabel", event.target.value)
}
/>
)}
</Field>
</FormSection>
<FormSection
@@ -31,6 +31,7 @@ describe("ApiariesPage", () => {
address: null,
status: "ACTIVE",
notes: null,
publicLocationLabel: null,
createdAt: "",
updatedAt: "",
deactivatedAt: null,
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RecordSelect } from "@/components/form/record-select";
import { honeyBatchOptionLabel } from "@/lib/honey-batches/labels";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -138,6 +139,12 @@ export default function NewBottlingBatchPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/bottling"
label="Zu den Abfüllungen"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Package}
title="Neue Abfüllung"
+14 -14
View File
@@ -2,41 +2,41 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Layers, ListTodo, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AuthUser } from "@/lib/auth/auth-context";
import { MoreSheet } from "./more-sheet";
import { bottomNavItems, isNavActive } from "./nav-config";
const ITEMS = [
{ label: "Start", href: "/dashboard", icon: LayoutDashboard },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{ label: "Suche", href: "/search", icon: Search },
];
export function BottomNav() {
export function BottomNav({ user }: { user: Pick<AuthUser, "permissions"> }) {
const pathname = usePathname();
return (
// Height and padding include the iOS home-indicator inset, so the labels
// stay tappable above it; --bottom-nav-height is shared with the content
// padding, StickySubmit and the quick-add button.
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-(--bottom-nav-height) border-t bg-background pb-(--safe-area-bottom) sm:hidden">
{ITEMS.map((item) => {
<nav
aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-30 flex h-(--bottom-nav-height) border-t bg-background pb-(--safe-area-bottom) sm:hidden"
>
{bottomNavItems().map((item) => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
const isActive = isNavActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
<Icon className="size-5" />
{item.label}
<Icon className="size-5" aria-hidden="true" />
{item.shortLabel ?? item.label}
</Link>
);
})}
<MoreSheet user={user} />
</nav>
);
}
@@ -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 { ColonyInspectionsCard } from "./colony-inspections";
import { fetchColonyInspections } from "@/lib/inspections/api";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/inspections/api", () => ({
fetchColonyInspections: vi.fn(),
updateInspection: vi.fn(),
}));
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ColonyInspectionsCard colonyId="colony-1" />
</QueryClientProvider>,
);
}
const base = {
id: "i-1",
colonyId: "colony-1",
date: "2026-06-01T00:00:00.000Z",
colonyStrength: 8,
queenSeen: null,
healthIssues: null,
varroaIssues: null,
measures: null,
};
describe("ColonyInspectionsCard", () => {
it("shows the brood frame count when there is one", async () => {
vi.mocked(fetchColonyInspections).mockResolvedValue([{ ...base, broodFrames: 4 }] as never);
renderCard();
expect(await screen.findByText(/· 4 Brutwaben/)).toBeInTheDocument();
});
it("shows nothing about brood frames when the field is null or absent", async () => {
vi.mocked(fetchColonyInspections).mockResolvedValue([
{ ...base, broodFrames: null },
{ ...base, id: "i-2" },
] as never);
renderCard();
await screen.findAllByText("8 Waben");
expect(screen.queryByText(/Brutwaben/)).not.toBeInTheDocument();
expect(screen.queryByText(/undefined/)).not.toBeInTheDocument();
});
});
@@ -54,6 +54,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
{inspection.colonyStrength !== null
? `${inspection.colonyStrength} Waben`
: "Stärke unbekannt"}
{inspection.broodFrames != null ? ` · ${inspection.broodFrames} Brutwaben` : ""}
{inspection.queenSeen !== null
? ` · Königin ${inspection.queenSeen ? "gesehen" : "nicht gesehen"}`
: ""}
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyVarroaCard } from "./colony-varroa";
import { fetchColonyVarroaMeasurements, fetchVarroaThreshold } from "@/lib/varroa/api";
import type { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
vi.mock("@/lib/varroa/api", () => ({
fetchColonyVarroaMeasurements: vi.fn(),
fetchVarroaThreshold: vi.fn(),
}));
vi.mock("./varroa-edit-dialog", () => ({ VarroaEditDialog: () => null }));
function measurement(
id: string,
date: string,
method: VarroaMethod,
calculatedValue: number | null,
): VarroaMeasurement {
return {
id,
colonyId: "c-1",
userId: "u-1",
date: `${date}T00:00:00.000Z`,
method,
durationMinutes: 4320,
miteCount: 12,
calculatedValue,
assessment: null,
notes: null,
createdAt: `${date}T00:00:00.000Z`,
};
}
const MEASUREMENTS = [
measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4),
measurement("b", "2026-06-08", "POWDERED_SUGAR", 99),
measurement("c", "2026-06-15", "NATURAL_MITE_FALL", 8),
];
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ColonyVarroaCard colonyId="c-1" />
</QueryClientProvider>,
);
}
describe("ColonyVarroaCard trend", () => {
beforeEach(() => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue(MEASUREMENTS);
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
});
it("the chart ignores powdered sugar counts", async () => {
const { container } = renderCard();
await screen.findByRole("img");
expect(container.querySelectorAll("svg circle")).toHaveLength(2);
});
it("draws the threshold line", async () => {
const { container } = renderCard();
expect(await screen.findByText("Schwelle 10/Tag")).toBeInTheDocument();
expect(container.querySelector("svg line[stroke-dasharray]")).not.toBeNull();
});
it("has an accessible summary", async () => {
renderCard();
await screen.findByText("Schwelle 10/Tag");
const chart = screen.getByRole("img");
expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Schwelle 10\/Tag/);
expect(chart).toHaveAccessibleDescription(/2 Messungen/);
});
it("shows only the value as text with fewer than two natural-fall points", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([MEASUREMENTS[0], MEASUREMENTS[1]]);
const { container } = renderCard();
expect(await screen.findByText(/Letzte Messung 4,0 Milben\/Tag am 01\.06\.2026/)).toBeInTheDocument();
expect(container.querySelector("svg circle")).toBeNull();
});
it("draws one marker per measurement even on the same day", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4),
measurement("b", "2026-06-01", "NATURAL_MITE_FALL", 6),
measurement("c", "2026-06-15", "NATURAL_MITE_FALL", 8),
]);
const { container } = renderCard();
await screen.findByRole("img");
expect(container.querySelectorAll("svg circle")).toHaveLength(3);
});
});
@@ -1,9 +1,13 @@
"use client";
import Link from "next/link";
import { useId } from "react";
import { Bug } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
import { VarroaMeasurement } from "@/lib/varroa/types";
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold";
import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
@@ -11,35 +15,117 @@ import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { VarroaEditDialog } from "./varroa-edit-dialog";
const CHART_WIDTH = 280;
const CHART_HEIGHT = 80;
const CHART_PADDING = 8;
const CHART_WIDTH = 300;
const CHART_HEIGHT = 120;
const PAD_X = 12;
const PAD_TOP = 12;
const PAD_BOTTOM = 12;
const MARKER_RADIUS = 4;
const LABEL_GAP = 4;
function VarroaTrendChart({ values }: { values: number[] }) {
if (values.length < 2) {
interface TrendPoint {
id: string;
time: number;
rate: number;
}
const DAY_FORMAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function formatDate(time: number): string {
return DAY_FORMAT.format(new Date(time));
}
/** Only the natural mite fall is a rate per day; other methods are not comparable. */
function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
return measurements
.filter((m) => m.method === "NATURAL_MITE_FALL" && m.calculatedValue !== null)
.map((m) => ({ id: m.id, time: new Date(m.date).getTime(), rate: m.calculatedValue as number }))
.sort((left, right) => left.time - right.time);
}
function summaryOf(last: TrendPoint, threshold: number | null): string {
const base = `Letzte Messung ${formatMitesPerDay(last.rate)} Milben/Tag am ${formatDate(last.time)}`;
return threshold === null ? base : `${base}, Schwelle ${formatThreshold(threshold)}/Tag`;
}
function VarroaTrendChart({
points,
threshold,
}: {
points: TrendPoint[];
threshold: number | null;
}) {
const titleId = useId();
const descId = useId();
const last = points[points.length - 1];
if (!last) {
return null;
}
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = max - min || 1;
const usableWidth = CHART_WIDTH - CHART_PADDING * 2;
const usableHeight = CHART_HEIGHT - CHART_PADDING * 2;
const points = values
.map((value, index) => {
const x = CHART_PADDING + (index / (values.length - 1)) * usableWidth;
const y = CHART_PADDING + usableHeight - ((value - min) / range) * usableHeight;
return `${x},${y}`;
})
.join(" ");
const summary = summaryOf(last, threshold);
if (points.length < 2) {
return <p className="font-medium">{summary}</p>;
}
const first = points[0];
const yMax = Math.max(...points.map((p) => p.rate), threshold ?? 0, 1);
const timeRange = last.time - first.time || 1;
const usableWidth = CHART_WIDTH - PAD_X * 2;
const usableHeight = CHART_HEIGHT - PAD_TOP - PAD_BOTTOM;
const xOf = (time: number) => PAD_X + ((time - first.time) / timeRange) * usableWidth;
const yOf = (rate: number) => PAD_TOP + usableHeight - (rate / yMax) * usableHeight;
const line = points.map((p) => `${xOf(p.time)},${yOf(p.rate)}`).join(" ");
return (
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary"
role="img"
aria-label="Verlauf der Varroamessungen"
aria-labelledby={titleId}
aria-describedby={descId}
>
<polyline points={points} fill="none" stroke="currentColor" strokeWidth={2} />
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
<desc id={descId}>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}`}
</desc>
{threshold !== null ? (
<g className="text-muted-foreground">
<line
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={yOf(threshold)}
y2={yOf(threshold)}
stroke="currentColor"
strokeWidth={1.5}
strokeDasharray="6 4"
/>
<text
x={CHART_WIDTH - PAD_X}
y={yOf(threshold) - LABEL_GAP}
textAnchor="end"
fontSize={10}
fill="currentColor"
>
{`Schwelle ${formatThreshold(threshold)}/Tag`}
</text>
</g>
) : null}
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
<circle
key={p.id}
cx={xOf(p.time)}
cy={yOf(p.rate)}
r={MARKER_RADIUS}
fill="currentColor"
className="stroke-background"
strokeWidth={2}
/>
))}
</svg>
);
}
@@ -50,6 +136,8 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
queryFn: () => fetchColonyVarroaMeasurements(colonyId),
});
const threshold = useVarroaThreshold();
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
@@ -77,11 +165,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
>
{(measurements) => (
<>
<VarroaTrendChart
values={measurements
.map((measurement) => measurement.miteCount ?? measurement.calculatedValue)
.filter((value): value is number => value !== null)}
/>
<VarroaTrendChart points={trendPoints(measurements)} threshold={threshold} />
{measurements.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
@@ -6,10 +6,18 @@ import { InspectionEditDialog } from "./inspection-edit-dialog";
import { updateInspection } from "@/lib/inspections/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { fetchDocuments } from "@/lib/documents/api";
import type { Inspection } from "@/lib/inspections/types";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({ updateInspection: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({
fetchDocuments: vi.fn(),
uploadDocument: vi.fn(),
updateDocument: vi.fn(),
deleteDocument: vi.fn(),
downloadDocument: vi.fn(),
}));
const inspection: Inspection = {
id: "i-1",
@@ -20,6 +28,7 @@ const inspection: Inspection = {
temperature: null,
colonyStrength: 8,
broodAmount: null,
broodFrames: null,
broodPattern: 3,
openBroodPresent: true,
cappedBroodPresent: true,
@@ -38,13 +47,13 @@ const inspection: Inspection = {
createdAt: "2026-06-01T00:00:00.000Z",
};
async function openDialog() {
async function openDialog(current: Inspection = inspection) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<InspectionEditDialog inspection={inspection} />
<InspectionEditDialog inspection={current} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
@@ -57,6 +66,28 @@ describe("InspectionEditDialog", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(updateInspection).mockReset();
vi.mocked(fetchDocuments).mockReset().mockResolvedValue([]);
});
it("has a „Fotos“ section with the inspection's files", async () => {
vi.mocked(fetchDocuments).mockResolvedValue([
{
id: "doc-1",
entityType: "INSPECTION",
entityId: "i-1",
fileName: "durchsicht-2026-06-01-1.jpg",
mimeType: "image/jpeg",
sizeBytes: 2048,
uploadedByUserId: "u-1",
createdAt: "2026-06-01T10:00:00.000Z",
},
]);
const dialog = await openDialog();
const photos = within(dialog).getByRole("group", { name: "Fotos" });
expect(await within(photos).findByText("durchsicht-2026-06-01-1.jpg")).toBeInTheDocument();
expect(fetchDocuments).toHaveBeenCalledWith("INSPECTION", "i-1");
expect(within(photos).getByRole("button", { name: "Datei hochladen" })).toBeInTheDocument();
});
it("sends the tapped rating as a number", async () => {
@@ -156,4 +187,61 @@ describe("InspectionEditDialog", () => {
await screen.findByText("Datum liegt in der Zukunft"),
).toBeInTheDocument();
});
it("shows Brutmenge (alt) only when the inspection has one", async () => {
await openDialog();
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
});
it("keeps the old free-text Brutmenge editable when the inspection has one", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, broodAmount: "4 Waben" });
expect(screen.getByLabelText(/Brutmenge \(alt\)/)).toHaveValue("4 Waben");
expect(
screen.getByText("Freitext aus früheren Durchsichten – neue Durchsichten zählen Brutwaben"),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ broodAmount: "4 Waben" });
});
it("clearing broodFrames sends null", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, broodFrames: 6 });
const field = screen.getByLabelText(/^Brutwaben/);
expect(field).toHaveValue("6");
await userEvent.clear(field);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ broodFrames: null });
});
it("stored swarmMood 5 shows display 1 selected", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, swarmMood: 5 });
const group = screen.getByRole("radiogroup", { name: /^Schwarmstimmung/ });
expect(within(group).getByRole("radio", { name: "1 von 5" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByText("1 = stark")).toBeInTheDocument();
expect(screen.getByText("5 = ruhig")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ swarmMood: 5 });
});
it("shows no Brutmenge (alt) when the field is absent from the payload", async () => {
const sparse = { ...inspection } as Partial<Inspection>;
delete sparse.broodAmount;
delete sparse.broodFrames;
await openDialog(sparse as Inspection);
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
});
});
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateInspection } from "@/lib/inspections/api";
import { Inspection } from "@/lib/inspections/types";
import type { RatingDirection } from "@/lib/forms/rating";
import { errorMessage } from "@/lib/forms/error-message";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -12,7 +13,9 @@ import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { RatingScale } from "@/components/form/rating-scale";
import { ChoiceGroup } from "@/components/form/choice-group";
import { NumberStepper } from "@/components/form/number-stepper";
import { TriStateField } from "@/components/form/tri-state-field";
import { DocumentUploadCard } from "@/components/document-upload-card";
import {
Dialog,
DialogContent,
@@ -41,6 +44,7 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues {
temperature: inspection.temperature?.toString() ?? "",
colonyStrength: inspection.colonyStrength?.toString() ?? "",
broodAmount: inspection.broodAmount ?? "",
broodFrames: inspection.broodFrames?.toString() ?? "",
broodPattern: inspection.broodPattern,
// null = "Nicht geprüft" stays null; it must not become a "Nein".
openBroodPresent: inspection.openBroodPresent,
@@ -59,6 +63,9 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues {
varroaIssues: inspection.varroaIssues ?? "",
measures: inspection.measures ?? "",
notes: inspection.notes ?? "",
weatherAuto: false,
// A saved inspection takes its photos straight to the server (Fotos below).
photoSessionId: "",
};
}
@@ -67,10 +74,18 @@ const RATINGS: {
label: string;
low: string;
high: string;
direction?: RatingDirection;
}[] = [
{ key: "gentleness", label: "Sanftmut", low: "stechlustig", high: "sehr sanft" },
{ key: "combBehavior", label: "Wabensitz", low: "läuft weg", high: "sitzt ruhig" },
{ key: "swarmMood", label: "Schwarmstimmung", low: "keine", high: "stark" },
// Stored 1 = ruhig, 5 = stark; shown mirrored so the good end is on the right.
{
key: "swarmMood",
label: "Schwarmstimmung",
low: "ruhig",
high: "stark",
direction: "lower-is-better",
},
];
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
@@ -187,31 +202,48 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
/>
)}
</Field>
<Field label="Brutmenge" hint="z. B. „4 Waben“">
<Field label="Brutwaben" hint="Waben mit Brut (offen oder verdeckelt)">
{({ id, describedBy }) => (
<Input
<NumberStepper
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.broodAmount}
onChange={(event) => set("broodAmount", event.target.value)}
value={values.broodFrames}
onChange={(count) => set("broodFrames", count)}
unitLabel="Waben mit Brut"
decrementLabel="Eine Brutwabe weniger"
incrementLabel="Eine Brutwabe mehr"
describedBy={describedBy}
/>
)}
</Field>
{inspection.broodAmount != null ? (
<Field
label="Brutmenge (alt)"
hint="Freitext aus früheren Durchsichten – neue Durchsichten zählen Brutwaben"
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.broodAmount}
onChange={(event) => set("broodAmount", event.target.value)}
/>
)}
</Field>
) : null}
</FormSection>
<FormSection step={3} title="Stärke & Verhalten">
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
{({ id, describedBy }) => (
<Input
<NumberStepper
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.colonyStrength}
onChange={(event) => set("colonyStrength", event.target.value)}
onChange={(count) => set("colonyStrength", count)}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
describedBy={describedBy}
/>
)}
</Field>
@@ -225,6 +257,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
onChange={(next) => set(rating.key, next)}
lowLabel={rating.low}
highLabel={rating.high}
direction={rating.direction}
/>
)}
</Field>
@@ -325,6 +358,10 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
</Field>
</FormSection>
<FormSection step={6} title="Fotos">
<DocumentUploadCard entityType="INSPECTION" entityId={inspection.id} />
</FormSection>
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
</form>
</DialogContent>
@@ -24,11 +24,11 @@ const measurement: VarroaMeasurement = {
createdAt: "2026-06-01T00:00:00.000Z",
};
async function openDialog() {
async function openDialog(item: VarroaMeasurement = measurement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<VarroaEditDialog measurement={measurement} />
<VarroaEditDialog measurement={item} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
@@ -47,9 +47,10 @@ describe("VarroaEditDialog", () => {
it("sends emptied optional fields as null so a wrong value can be removed", async () => {
await openDialog();
for (const label of ["Milbenzahl", "Zeitraum (Minuten)", "Milben pro Tag", "Einschätzung", "Notizen"]) {
for (const label of ["Milbenzahl", "Milben pro Tag", "Einschätzung", "Notizen"]) {
await userEvent.clear(screen.getByLabelText(new RegExp(`^${label.replace(/[()]/g, "\\$&")}`)));
}
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
@@ -78,4 +79,24 @@ describe("VarroaEditDialog", () => {
notes: "Windel verrutscht",
});
});
it("a stored 2000 minutes is sent back unchanged", async () => {
await openDialog({ ...measurement, durationMinutes: 2000 });
expect(screen.getByText(/anders: 1,39 Tage \(2000 Minuten\)/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
expect(input).toMatchObject({ durationMinutes: 2000 });
});
it("keeps the minutes field for other methods", async () => {
await openDialog({ ...measurement, method: "WASHING_METHOD" });
expect(screen.getByLabelText(/^Zeitraum \(Minuten\)/)).toHaveValue(4320);
});
it("explains that a manual mites-per-day value takes precedence", async () => {
await openDialog();
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
});
});
@@ -27,6 +27,7 @@ import {
hasAnyValue,
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient();
@@ -72,6 +73,8 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
setSubmitError(errorMessage(error, "Varroamessung konnte nicht gespeichert werden.")),
});
const isNaturalFall = method === "NATURAL_MITE_FALL";
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
@@ -127,7 +130,10 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
/>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
{isNaturalFall ? (
<VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} />
) : null}
<div className={isNaturalFall ? undefined : "grid grid-cols-2 gap-3"}>
<Field label="Milbenzahl">
{({ id }) => (
<Input
@@ -141,21 +147,26 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
/>
)}
</Field>
<Field label="Zeitraum (Minuten)">
{({ id }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={durationMinutes}
onChange={(event) => setDurationMinutes(event.target.value)}
/>
)}
</Field>
{isNaturalFall ? null : (
<Field label="Zeitraum (Minuten)">
{({ id }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={durationMinutes}
onChange={(event) => setDurationMinutes(event.target.value)}
/>
)}
</Field>
)}
</div>
<Field label="Milben pro Tag">
<Field
label="Milben pro Tag"
hint="Wird beim Speichern aus Milbenzahl und Zeitraum neu berechnet – ein eigener, geänderter Wert hat Vorrang."
>
{({ id }) => (
<Input
id={id}
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -93,6 +94,12 @@ export default function NewColonyPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/colonies"
label="Zu den Völkern"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Layers}
title="Neues Volk"
@@ -10,6 +10,7 @@ import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -84,6 +85,12 @@ export default function NewEquipmentPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/equipment"
label="Zum Inventar"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Wrench}
title="Neuer Inventargegenstand"
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -120,6 +121,12 @@ export default function NewExtractionPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/extractions"
label="Zu den Schleuderungen"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Droplet}
title="Neue Schleuderung"
@@ -2,19 +2,19 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Utensils } from "lucide-react";
import { createFeeding } from "@/lib/feedings/api";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueFeeding } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -52,6 +52,10 @@ const FEED_TYPE_OPTIONS = choiceOptions(FEED_TYPE_LABELS, FEED_TYPES);
export default function NewFeedingPage() {
const router = useRouter();
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const queryClient = useQueryClient();
@@ -60,7 +64,6 @@ export default function NewFeedingPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("feeding-new", values, dirty);
function set<K extends keyof FeedingFormValues>(
@@ -74,14 +77,20 @@ export default function NewFeedingPage() {
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createFeeding>[0]) =>
createFeeding(input, { idempotencyKey }),
onSuccess: (feeding) => {
const capture = useCaptureMutation({
create: createFeeding,
queue: queueFeeding,
failureMessage: "Fütterung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${input.colonyId}`);
return;
}
const feeding = outcome.result;
void queryClient.invalidateQueries({
queryKey: ["colonies", feeding.colonyId, "feedings"],
});
@@ -91,29 +100,10 @@ export default function NewFeedingPage() {
toast.success("Fütterung gespeichert");
router.push(`/colonies/${feeding.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Fütterung konnte nicht gespeichert werden.",
),
);
return;
}
void queueFeeding(variables, idempotencyKey).then(() => {
draft.clear();
toast.info(
"Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -123,7 +113,7 @@ export default function NewFeedingPage() {
?.focus();
return;
}
mutation.mutate(toFeedingInput(values));
capture.submit(toFeedingInput(values));
}
function restoreDraft() {
@@ -136,6 +126,12 @@ export default function NewFeedingPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Utensils}
title="Neue Fütterung"
@@ -266,12 +262,12 @@ export default function NewFeedingPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -279,9 +275,9 @@ export default function NewFeedingPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending
{capture.isPending
? "Wird gespeichert…"
: "Fütterung speichern"}
</Button>
@@ -2,14 +2,9 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Boxes, AlertTriangle } from "lucide-react";
import { Scissors, AlertTriangle } from "lucide-react";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels";
@@ -17,15 +12,14 @@ import { isLivingColony } from "@/lib/colonies/status";
import { createHarvest } from "@/lib/harvests/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { todayLocalIsoDate } from "@/lib/dates";
import { ApiError } from "@/lib/auth/api-client";
import { queueHarvest } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { RecordSelect } from "@/components/form/record-select";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -74,7 +68,6 @@ export default function NewHarvestPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("harvest-new", values, dirty);
const apiaryColonies = (colonies ?? []).filter(
@@ -122,38 +115,27 @@ export default function NewHarvestPage() {
);
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createHarvest>[0]) =>
createHarvest(input, { idempotencyKey }),
onSuccess: (harvest) => {
const capture = useCaptureMutation({
create: createHarvest,
queue: queueHarvest,
failureMessage: "Ernte konnte nicht gespeichert werden.",
onSaved: (outcome) => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
toast.success("Ernte gespeichert");
router.push(`/harvests/${harvest.id}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(mutationError, "Ernte konnte nicht gespeichert werden."),
);
return;
}
void queueHarvest(variables, idempotencyKey).then(() => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.",
);
router.push("/harvests");
});
return;
}
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
toast.success("Ernte gespeichert");
router.push(`/harvests/${outcome.result.id}`);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -163,7 +145,7 @@ export default function NewHarvestPage() {
?.focus();
return;
}
mutation.mutate(toHarvestInput(values));
capture.submit(toHarvestInput(values));
}
function restoreDraft() {
@@ -176,8 +158,14 @@ export default function NewHarvestPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/harvests"
label="Zu den Ernten"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Boxes}
icon={Scissors}
title="Neue Ernte"
description="Pflicht sind Bienenstand, Datum und mindestens ein Volk."
/>
@@ -383,12 +371,12 @@ export default function NewHarvestPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -396,9 +384,9 @@ export default function NewHarvestPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending ? "Wird gespeichert…" : "Ernte speichern"}
{capture.isPending ? "Wird gespeichert…" : "Ernte speichern"}
</Button>
</StickySubmit>
</form>
@@ -83,6 +83,33 @@ describe("inspection form values", () => {
expect(input).toMatchObject({ weather: "sonnig", temperature: 0, colonyStrength: 8, swarmMood: 2 });
});
it("sends brood frames as a number, empty as null, and no legacy Brutmenge", () => {
const empty = toInspectionInput(initialInspectionValues("colony-1"));
const four = toInspectionInput({ ...initialInspectionValues("colony-1"), broodFrames: "4" });
expect(empty.broodFrames).toBeNull();
expect(four.broodFrames).toBe(4);
expect(empty.broodAmount).toBeNull();
});
it("starts weather as replaceable by the apiary weather", () => {
const values = initialInspectionValues("colony-1");
expect(values.broodFrames).toBe("");
expect(values.weatherAuto).toBe(true);
expect(toInspectionInput(values)).not.toHaveProperty("weatherAuto");
});
it("gives every new form its own photo session, kept in the draft but never sent", () => {
const first = initialInspectionValues("colony-1");
const second = initialInspectionValues("colony-1");
expect(first.photoSessionId).toMatch(/^[A-Za-z0-9_-]{16,}$/);
expect(second.photoSessionId).not.toBe(first.photoSessionId);
expect(JSON.parse(JSON.stringify(first)).photoSessionId).toBe(first.photoSessionId);
expect(toInspectionInput(first)).not.toHaveProperty("photoSessionId");
});
it("keeps 'not checked' through a draft or the offline queue (JSON)", () => {
const values = initialInspectionValues("colony-1");
@@ -2,6 +2,7 @@ import type { ChoiceOption } from "@/components/form/choice-group";
import type { createInspection } from "@/lib/inspections/api";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
export type FeedStatus = "ausreichend" | "niedrig" | "kritisch";
@@ -33,7 +34,10 @@ export interface InspectionFormValues {
weather: string;
temperature: string;
colonyStrength: string;
/** Legacy free text; only shown on edit when an old inspection has one. */
broodAmount: string;
/** Number of brood frames, "" = not counted. */
broodFrames: string;
broodPattern: number | null;
openBroodPresent: boolean | null;
cappedBroodPresent: boolean | null;
@@ -54,6 +58,17 @@ export interface InspectionFormValues {
varroaIssues: string;
measures: string;
notes: string;
/**
* true: weather and temperature come from the apiary weather prefill (or
* are still empty) and may be replaced when date or colony change. Any
* typing turns it off. Never sent to the API.
*/
weatherAuto: boolean;
/**
* The photos taken in this form (lib/photos/store.ts) - part of the draft,
* so a restored draft brings its photos back. Never sent to the API.
*/
photoSessionId: string;
}
export function initialInspectionValues(
@@ -66,6 +81,7 @@ export function initialInspectionValues(
temperature: "",
colonyStrength: "",
broodAmount: "",
broodFrames: "",
broodPattern: null,
openBroodPresent: null,
cappedBroodPresent: null,
@@ -81,6 +97,8 @@ export function initialInspectionValues(
varroaIssues: "",
measures: "",
notes: "",
weatherAuto: true,
photoSessionId: newIdempotencyKey(),
};
}
@@ -100,6 +118,7 @@ export function toInspectionInput(
temperature: emptyToNull(values.temperature, Number),
colonyStrength: emptyToNull(values.colonyStrength, Number),
broodAmount: emptyToNull(values.broodAmount),
broodFrames: emptyToNull(values.broodFrames, Number),
broodPattern: values.broodPattern ?? null,
openBroodPresent: values.openBroodPresent,
cappedBroodPresent: values.cappedBroodPresent,
@@ -1,19 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewInspectionPage from "./page";
import { createInspection } from "@/lib/inspections/api";
import { createInspection, fetchColonyInspections } from "@/lib/inspections/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { toast } from "sonner";
import type { Inspection } from "@/lib/inspections/types";
import type { DailyWeather } from "@/lib/weather/types";
import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
import { queueInspection } from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import {
attachSessionToInspection,
attachSessionToQueuedInspection,
discardSessionPhotos,
listSessionPhotos,
releaseSessionPhotos,
} from "@/lib/photos/store";
import { DRAFT_PREFIX } from "@/lib/forms/draft-storage";
import { initialInspectionValues } from "./inspection-form-values";
import { markColonyDone, readRound, startRound } from "@/lib/rounds/round-state";
const push = vi.fn();
let searchString = "colonyId=colony-1";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
useSearchParams: () => new URLSearchParams(searchString),
}));
// Without documents:upload the photo section stays hidden (most tests here).
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
}));
vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/field-api")>()),
fetchColonyOverview: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({
createInspection: vi.fn(),
fetchColonyInspections: vi.fn(),
}));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock("@/lib/offline/db", () => ({ queueInspection: vi.fn() }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5,
listSessionPhotos: vi.fn(),
addDraftPhoto: vi.fn(),
removePhoto: vi.fn(),
attachSessionToInspection: vi.fn(),
attachSessionToQueuedInspection: vi.fn(),
releaseSessionPhotos: vi.fn(),
discardSessionPhotos: vi.fn(),
}));
const NOTHING_SENT = { synced: 0, failed: 0, skippedForeign: 0 };
function renderPage() {
const client = new QueryClient({
@@ -29,17 +76,39 @@ function renderPage() {
describe("NewInspectionPage", () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
searchString = "colonyId=colony-1";
push.mockReset();
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(createInspection).mockReset();
vi.mocked(fetchColonyInspections).mockResolvedValue([]);
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.success).mockReset();
permissions = new Set();
vi.mocked(listSessionPhotos).mockReset().mockResolvedValue([]);
vi.mocked(attachSessionToInspection).mockReset().mockResolvedValue(undefined);
vi.mocked(attachSessionToQueuedInspection).mockReset().mockResolvedValue(undefined);
vi.mocked(releaseSessionPhotos).mockReset().mockResolvedValue(undefined);
vi.mocked(discardSessionPhotos).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
});
it("links back to the colony it was opened from", () => {
renderPage();
expect(screen.getByRole("link", { name: /Zum Volk/ })).toHaveAttribute(
"href",
"/colonies/colony-1",
);
});
it("marks what is required and keeps the extra fields folded", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(2);
expect(screen.queryByLabelText(/Wetter/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Honigraum/)).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Weitere Angaben/ }),
).toHaveAttribute("aria-expanded", "false");
@@ -139,4 +208,504 @@ describe("NewInspectionPage", () => {
expect(["INPUT", "SELECT", "TEXTAREA", "BUTTON"]).toContain(target?.tagName);
}
});
describe("capture helpers", () => {
const lastInspection: Inspection = {
id: "i-0",
colonyId: "colony-1",
userId: "u-1",
date: "2026-10-01T00:00:00.000Z",
weather: null,
temperature: null,
colonyStrength: 8,
broodAmount: null,
broodFrames: 5,
broodPattern: 3,
openBroodPresent: true,
cappedBroodPresent: null,
eggsPresent: true,
queenSeen: true,
queenCellsPresent: false,
swarmMood: 4,
gentleness: null,
combBehavior: null,
feedStatus: "niedrig",
honeySuperStatus: null,
healthIssues: null,
varroaIssues: null,
measures: null,
notes: null,
createdAt: "2026-10-01T00:00:00.000Z",
};
function checkedOf(group: string | RegExp) {
return within(screen.getByRole("radiogroup", { name: group }))
.getAllByRole("radio")
.find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent;
}
async function renderWithLast() {
vi.mocked(fetchColonyInspections).mockResolvedValue([lastInspection]);
renderPage();
await screen.findByText(/Letzte Durchsicht:/);
}
function dayWeather(date: string, overrides: Partial<DailyWeather> = {}): DailyWeather {
return {
date,
tempMaxC: 21.54,
tempMinC: 9,
precipitationMm: 0.4,
windSpeedMaxKmh: 12,
inspectionSuitable: true,
weatherCode: 2,
...overrides,
};
}
it("nothing is preselected from the last inspection", async () => {
await renderWithLast();
for (const group of [
"Königin gesehen",
"Stifte vorhanden",
"Offene Brut",
"Verdeckelte Brut",
"Weiselzellen",
]) {
expect(checkedOf(group)).toBe("Nicht geprüft");
}
expect(checkedOf(/^Futterstatus/)).toBe("Nicht geprüft");
for (const group of [/^Brutbild/, /^Sanftmut/, /^Wabensitz/, /^Schwarmstimmung/]) {
expect(checkedOf(group)).toBeUndefined();
}
expect(screen.getByLabelText(/^Brutwaben/)).toHaveValue("");
expect(screen.getByLabelText(/^Volksstärke/)).toHaveValue("");
});
it('„Letztes Mal: Ja · übernehmen" sets queenSeen on tap', async () => {
await renderWithLast();
await userEvent.click(screen.getByRole("button", { name: /übernehmen.*Königin gesehen/ }));
expect(checkedOf("Königin gesehen")).toBe("Ja");
expect(checkedOf("Stifte vorhanden")).toBe("Nicht geprüft");
});
it('„Alles wie letztes Mal" fills only untouched fields and can be undone', async () => {
await renderWithLast();
await userEvent.click(
within(screen.getByRole("radiogroup", { name: "Königin gesehen" })).getByRole("radio", {
name: "Nein",
}),
);
await userEvent.click(screen.getByRole("button", { name: "Alles wie letztes Mal" }));
expect(checkedOf("Königin gesehen")).toBe("Nein");
expect(checkedOf("Stifte vorhanden")).toBe("Ja");
expect(checkedOf("Verdeckelte Brut")).toBe("Nicht geprüft");
expect(screen.getByLabelText(/^Brutwaben/)).toHaveValue("5");
expect(checkedOf(/^Futterstatus/)).toBe("Niedrig");
const [message, options] = vi.mocked(toast.success).mock.calls[0];
expect(message).toMatch(/^\d+ Werte übernommen$/);
expect(options?.action).toMatchObject({ label: "Rückgängig" });
const action = options?.action as unknown as { onClick: () => void };
act(() => action.onClick());
expect(checkedOf("Königin gesehen")).toBe("Nein");
expect(checkedOf("Stifte vorhanden")).toBe("Nicht geprüft");
expect(screen.getByLabelText(/^Brutwaben/)).toHaveValue("");
expect(checkedOf(/^Futterstatus/)).toBe("Nicht geprüft");
});
it("fills weather and temperature from the apiary weather for the chosen date", async () => {
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [dayWeather(todayLocalIsoDate())],
});
renderPage();
await waitFor(() =>
expect(screen.getByLabelText(/^Wetter/)).toHaveValue(
"Teilweise bewölkt, 0.4 mm, Wind 12 km/h",
),
);
expect(screen.getByLabelText(/^Temperatur/)).toHaveValue(21.5);
expect(
screen.getByText("Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"),
).toBeInTheDocument();
});
it("re-fills auto weather when the date changes but never overwrites typed weather", async () => {
const now = new Date();
const other = toLocalIsoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [
dayWeather(todayLocalIsoDate()),
dayWeather(other, { weatherCode: 61, tempMaxC: 14 }),
],
});
renderPage();
await waitFor(() =>
expect((screen.getByLabelText(/^Wetter/) as HTMLInputElement).value).toMatch(/^Teilweise/),
);
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: other } });
await waitFor(() =>
expect((screen.getByLabelText(/^Wetter/) as HTMLInputElement).value).toMatch(/^Regen/),
);
expect(screen.getByLabelText(/^Temperatur/)).toHaveValue(14);
await userEvent.clear(screen.getByLabelText(/^Wetter/));
await userEvent.type(screen.getByLabelText(/^Wetter/), "windig");
fireEvent.change(screen.getByLabelText(/^Datum/), {
target: { value: todayLocalIsoDate() },
});
await waitFor(() =>
expect(screen.getByLabelText(/^Datum/)).toHaveValue(todayLocalIsoDate()),
);
expect(screen.getByLabelText(/^Wetter/)).toHaveValue("windig");
});
it('swarm mood: tapping display 5 („ruhig") sends swarmMood 1', async () => {
vi.mocked(createInspection).mockResolvedValue({
id: "i-1",
colonyId: "colony-1",
} as never);
renderPage();
expect(screen.getByText("1 = stark")).toBeInTheDocument();
expect(screen.getByText("5 = ruhig")).toBeInTheDocument();
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Schwarmstimmung/ })).getByRole("radio", {
name: "5 von 5",
}),
);
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ swarmMood: 1 });
});
it("brood frames stepper sends broodFrames 5", async () => {
vi.mocked(createInspection).mockResolvedValue({
id: "i-1",
colonyId: "colony-1",
} as never);
renderPage();
const plus = screen.getByRole("button", { name: /Brutwabe mehr/ });
for (let i = 0; i < 5; i += 1) await userEvent.click(plus);
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({
broodFrames: 5,
broodAmount: null,
});
});
it("has no Brutmenge field", () => {
renderPage();
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
});
it("announces politely when auto weather is refreshed or missing for the day", async () => {
const now = new Date();
const other = toLocalIsoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2));
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [dayWeather(todayLocalIsoDate())],
});
renderPage();
const live = await screen.findByRole("status");
expect(live).toHaveAttribute("aria-live", "polite");
await waitFor(() => expect(live).toHaveTextContent("Wetter aktualisiert"));
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: other } });
await waitFor(() => expect(live).toHaveTextContent("Keine Wetterdaten für diesen Tag"));
expect(screen.getByLabelText(/^Wetter/)).toHaveValue("");
});
it("changes the announcement on every automatic update so it is read again", async () => {
const now = new Date();
const other = toLocalIsoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [dayWeather(todayLocalIsoDate()), dayWeather(other, { weatherCode: 61, tempMaxC: 14 })],
});
renderPage();
const live = await screen.findByRole("status");
await waitFor(() => expect(live).toHaveTextContent("Wetter aktualisiert"));
const first = live.textContent;
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: other } });
await waitFor(() => expect(live.textContent).not.toBe(first));
expect(live).toHaveTextContent("Wetter aktualisiert");
expect(live).toHaveTextContent("14.0");
});
it("stays silent once the weather was typed by hand", async () => {
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
renderPage();
await userEvent.type(screen.getByLabelText(/^Wetter/), "windig");
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
});
describe("in a stand round", () => {
const apiary = { id: "apiary-1", name: "Obstwiese" };
const colonies = [
{ id: "colony-1", code: "B2" },
{ id: "colony-2", code: "B3" },
{ id: "colony-3", code: "B4" },
];
function overviewOf(ids: string[]) {
return {
apiaryId: "apiary-1",
inspectionsVisible: true,
rows: colonies.filter((colony) => ids.includes(colony.id)).map((colony) => ({ ...colony })),
};
}
beforeEach(() => {
searchString = "colonyId=colony-1&round=apiary-1";
vi.mocked(fetchColonies).mockResolvedValue(
colonies.map((colony) => ({ ...colony, status: "ACTIVE", apiaryId: "apiary-1" })) as never,
);
vi.mocked(fetchColonyOverview).mockResolvedValue(
overviewOf(["colony-1", "colony-2", "colony-3"]) as never,
);
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
vi.mocked(queueInspection).mockReset();
startRound("user-1", apiary, colonies);
});
async function save() {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
it("shows where in the round the beekeeper is", async () => {
renderPage();
expect(await screen.findByText("Runde Obstwiese: 1 von 3")).toBeInTheDocument();
});
it("makes the chip and the done-mark follow the colony selected in the form", async () => {
vi.mocked(fetchColonies).mockResolvedValue([
...colonies.map((colony) => ({ ...colony, status: "ACTIVE", apiaryId: "apiary-1" })),
{ id: "colony-9", code: "X9", status: "ACTIVE", apiaryId: "apiary-9" },
] as never);
markColonyDone("user-1", "colony-2");
renderPage();
expect(await screen.findByText("Runde Obstwiese: 2 von 3")).toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "B3" }));
expect(await screen.findByText("Runde Obstwiese: 1 von 3")).toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "X9" }));
await waitFor(() => expect(screen.queryByText(/^Runde /)).not.toBeInTheDocument());
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "B4" }));
expect(await screen.findByText("Runde Obstwiese: 2 von 3")).toBeInTheDocument();
await save();
await screen.findByText("Gespeichert");
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-2", "colony-3"]);
});
it("after save shows „Weiter mit B3“ instead of going to the colony", async () => {
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await save();
expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument();
expect(screen.getByText("Gespeichert")).toBeInTheDocument();
expect(push).not.toHaveBeenCalled();
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-1"]);
await userEvent.click(screen.getByRole("button", { name: "Weiter mit B3" }));
expect(push).toHaveBeenCalledWith("/inspections/new?colonyId=colony-2&round=apiary-1");
});
it("skips a colony that left the apiary during the round", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overviewOf(["colony-1", "colony-3"]) as never,
);
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await waitFor(() => expect(fetchColonyOverview).toHaveBeenCalled());
await save();
expect(await screen.findByRole("button", { name: "Weiter mit B4" })).toBeInTheDocument();
});
it("„Runde beenden“ ends the round", async () => {
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await save();
await userEvent.click(await screen.findByRole("button", { name: "Runde beenden" }));
expect(readRound("user-1")).toBeNull();
expect(toast.info).toHaveBeenCalledWith("Runde beendet – 1 von 3 Völkern");
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
});
it("an offline save also advances the round", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockResolvedValue(undefined);
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await save();
expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument();
expect(queueInspection).toHaveBeenCalled();
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-1"]);
expect(push).not.toHaveBeenCalled();
});
it("finishes the round when nothing is left to do", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(overviewOf(["colony-1"]) as never);
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await waitFor(() => expect(fetchColonyOverview).toHaveBeenCalled());
await save();
expect(await screen.findByText("Runde abgeschlossen – alle 1 Völker")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Zum Stand" }));
expect(readRound("user-1")).toBeNull();
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
});
it("behaves as usual when the round belongs to another apiary", async () => {
searchString = "colonyId=colony-1&round=apiary-9";
renderPage();
await save();
await waitFor(() => expect(push).toHaveBeenCalledWith("/colonies/colony-1"));
expect(screen.queryByText(/^Runde /)).not.toBeInTheDocument();
expect(readRound("user-1")?.doneColonyIds).toEqual([]);
});
});
describe("photos", () => {
const DRAFT_KEY = `${DRAFT_PREFIX}user-1:inspection-new`;
beforeEach(() => {
permissions = new Set(["documents:upload"]);
vi.mocked(queueInspection).mockReset();
});
afterEach(() => {
permissions = new Set();
});
/** The photo session the form currently shows. */
function shownSession(): string {
const calls = vi.mocked(listSessionPhotos).mock.calls;
return calls[calls.length - 1][0];
}
function storeDraft(photoSessionId: string) {
window.localStorage.setItem(
DRAFT_KEY,
JSON.stringify({
savedAt: Date.now(),
values: { ...initialInspectionValues("colony-1"), photoSessionId, colonyStrength: "8" },
}),
);
}
async function save() {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
it("offers „Foto aufnehmen“ with the camera", async () => {
renderPage();
expect(await screen.findByRole("button", { name: "Foto aufnehmen" })).toBeInTheDocument();
expect(document.querySelector('input[type="file"]')).toHaveAttribute(
"capture",
"environment",
);
});
it("hands the photos to the saved inspection and sends them in the background", async () => {
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
await save();
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(attachSessionToInspection).toHaveBeenCalledWith(shownSession(), "i-1");
expect(vi.mocked(attachSessionToInspection).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
expect(attachSessionToQueuedInspection).not.toHaveBeenCalled();
});
it("ties the photos to the queued inspection before it is queued", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockResolvedValue(undefined);
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
await save();
await waitFor(() => expect(queueInspection).toHaveBeenCalled());
const [, idempotencyKey] = vi.mocked(queueInspection).mock.calls[0];
expect(attachSessionToQueuedInspection).toHaveBeenCalledWith(shownSession(), idempotencyKey);
// A sync that sends the inspection right away already finds its photos.
expect(vi.mocked(attachSessionToQueuedInspection).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(queueInspection).mock.invocationCallOrder[0],
);
expect(attachSessionToInspection).not.toHaveBeenCalled();
expect(releaseSessionPhotos).not.toHaveBeenCalled();
});
it("gives the photos back to the form when the inspection cannot be queued", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockRejectedValue(new Error("QuotaExceededError"));
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
await save();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Durchsicht konnte nicht gespeichert werden.",
);
expect(releaseSessionPhotos).toHaveBeenCalledWith(shownSession());
});
it("„Verwerfen“ of an earlier draft also deletes its photos", async () => {
storeDraft("draft-session-0001");
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
expect(discardSessionPhotos).toHaveBeenCalledWith("draft-session-0001");
expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();
});
it("brings the photos of a restored draft back", async () => {
storeDraft("draft-session-0001");
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
const freshSession = shownSession();
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
await waitFor(() => expect(shownSession()).toBe("draft-session-0001"));
expect(screen.getByDisplayValue("8")).toBeInTheDocument();
// The fresh form's own photos give way to the restored entry, like its values.
expect(discardSessionPhotos).toHaveBeenCalledWith(freshSession);
expect(discardSessionPhotos).not.toHaveBeenCalledWith("draft-session-0001");
});
});
});
@@ -1,19 +1,44 @@
"use client";
import { FormEvent, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, ClipboardCheck } from "lucide-react";
import { createInspection } from "@/lib/inspections/api";
import { ApiError } from "@/lib/auth/api-client";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { weatherPrefillFor } from "@/lib/weather/describe";
import {
daysSince,
previousLabel,
previousValueOf,
takeOverAll,
undoTakeOver,
type TakeoverField,
} from "@/lib/inspections/takeover";
import { useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { queueInspection } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { syncPendingEntries } from "@/lib/offline/sync";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import {
attachSessionToInspection,
attachSessionToQueuedInspection,
discardSessionPhotos,
releaseSessionPhotos,
} from "@/lib/photos/store";
import { toCalendarDay, todayLocalIsoDate } from "@/lib/dates";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { useStandRound } from "@/lib/rounds/use-stand-round";
import type { StandRound } from "@/lib/rounds/round-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -22,8 +47,11 @@ import { FormSection } from "@/components/form/form-section";
import { RatingScale } from "@/components/form/rating-scale";
import { ChoiceGroup } from "@/components/form/choice-group";
import { TriStateField } from "@/components/form/tri-state-field";
import { NumberStepper } from "@/components/form/number-stepper";
import { PreviousValueHint } from "@/components/form/previous-value-hint";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { PhotoCaptureField } from "@/components/photos/photo-capture-field";
import {
FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS,
@@ -32,68 +60,225 @@ import {
toInspectionInput,
} from "./inspection-form-values";
export default function NewInspectionPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const UNDO_TOAST_MS = 8000;
const WEATHER_UPDATED = "Wetter aktualisiert";
const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag";
const TAKEOVER_LABELS: Record<TakeoverField, string> = {
queenSeen: "Königin gesehen",
eggsPresent: "Stifte vorhanden",
openBroodPresent: "Offene Brut",
cappedBroodPresent: "Verdeckelte Brut",
queenCellsPresent: "Weiselzellen",
broodPattern: "Brutbild",
broodFrames: "Brutwaben",
colonyStrength: "Volksstärke",
gentleness: "Sanftmut",
combBehavior: "Wabensitz",
swarmMood: "Schwarmstimmung",
feedStatus: "Futterstatus",
honeySuperStatus: "Honigraum",
};
type SetValues = (update: (current: InspectionFormValues) => InspectionFormValues) => void;
/** "2026-10-01T00:00:00.000Z" -> "01.10.2026", whatever the phone's time zone. */
function formatGermanDay(isoDate: string): string {
const [year, month, day] = toCalendarDay(isoDate).split("-");
return `${day}.${month}.${year}`;
}
function daysAgoText(days: number): string {
if (days <= 0) return "heute";
return days === 1 ? "vor 1 Tag" : `vor ${days} Tagen`;
}
/**
* Fills weather and temperature from the apiary's weather data for the chosen
* date - visible and editable. Only while the fields are still the automatic
* ones (`weatherAuto`): once the beekeeper types, they are left alone.
*/
function useWeatherAutofill(values: InspectionFormValues, setValues: SetValues): string {
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const apiaryId = colonies?.find((colony) => colony.id === values.colonyId)?.apiaryId ?? "";
const { data: weather } = useQuery({
queryKey: ["apiaries", apiaryId, "weather"],
queryFn: () => fetchApiaryWeather(apiaryId),
enabled: apiaryId !== "",
});
const prefill = useMemo(
() => (weather ? weatherPrefillFor(weather.daily, values.date) : null),
[weather, values.date],
);
const { weatherAuto, weather: text, temperature } = values;
useEffect(() => {
if (!weatherAuto) return;
const nextWeather = prefill?.weather ?? "";
const nextTemperature = prefill?.temperature ?? "";
if (text === nextWeather && temperature === nextTemperature) return;
setValues((current) =>
current.weatherAuto
? { ...current, weather: nextWeather, temperature: nextTemperature }
: current,
);
}, [weatherAuto, text, temperature, prefill, setValues]);
// Hand-typed weather is the beekeeper's own: nothing to announce about it.
if (!weatherAuto || !weather || !values.date) return "";
// The date (and values) are part of the text: it must change with every
// update, or a screen reader would not read the new result again.
const day = formatGermanDay(values.date);
if (!prefill) return `${WEATHER_MISSING} (${day})`;
const summary = [prefill.weather, prefill.temperature ? `${prefill.temperature} °C` : ""]
.filter(Boolean)
.join(", ");
return `${WEATHER_UPDATED} (${day}): ${summary}`;
}
/**
* Offline save: the form's photos are tied to the queue entry's key before
* the entry exists, so a sync that sends the inspection right away already
* finds them. If queueing fails, they belong to the open form again.
*/
async function queueInspectionWithPhotos(
photoSessionId: string,
input: CreateInspectionInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
await attachSessionToQueuedInspection(photoSessionId, idempotencyKey);
try {
await queueInspection(input, idempotencyKey, ownerUserId);
} catch (error) {
await releaseSessionPhotos(photoSessionId);
throw error;
}
}
/**
* Online save: the photos belong to the saved inspection and go up in the
* background - inside a sync run, so a logout's cancelSync() stops them too.
*/
async function sendPhotosOf(
photoSessionId: string,
inspectionId: string,
userId: string | null,
): Promise<void> {
await attachSessionToInspection(photoSessionId, inspectionId);
if (userId) {
await syncPendingEntries(userId);
}
}
interface LastInspectionBarProps {
last: Inspection;
onTakeOverAll: () => void;
}
function LastInspectionBar({ last, onTakeOverAll }: LastInspectionBarProps) {
const days = daysSince(last.date, todayLocalIsoDate());
return (
<div className="flex flex-col items-start gap-2 rounded-xl bg-muted/50 p-3">
<p className="text-sm">
Letzte Durchsicht: {formatGermanDay(last.date)} · {daysAgoText(days)}
</p>
<Button type="button" variant="outline" className="min-h-11" onClick={onTakeOverAll}>
Alles wie letztes Mal
</Button>
</div>
);
}
interface InspectionCaptureFormProps {
initialColonyId: string;
/** Apiary of the stand round this form was opened from, if any. */
roundApiaryId: string | null;
backTarget: { href: string; label: string };
}
interface RoundSaved {
round: StandRound;
savedOffline: boolean;
}
/** "Runde Stand: k von n" - k counts this colony unless it is already done. */
function roundChipText(round: StandRound, colonyId: string): string {
const position = round.doneColonyIds.length + (round.doneColonyIds.includes(colonyId) ? 0 : 1);
return `Runde ${round.apiaryName}: ${position} von ${round.colonies.length}`;
}
function InspectionCaptureForm({
initialColonyId,
roundApiaryId,
backTarget,
}: InspectionCaptureFormProps) {
const router = useRouter();
const queryClient = useQueryClient();
const userId = useCurrentUserId();
const [values, setValues] = useState<InspectionFormValues>(() =>
initialInspectionValues(searchParams.get("colonyId") ?? ""),
initialInspectionValues(initialColonyId),
);
const [dirty, setDirty] = useState(false);
const [colonyError, setColonyError] = useState<string | null>(null);
const [dateError, setDateError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("inspection-new", values, dirty);
const { inspection: last } = useLastInspection(values.colonyId);
const weatherNotice = useWeatherAutofill(values, setValues);
const stand = useStandRound(roundApiaryId, values.colonyId);
const [roundSaved, setRoundSaved] = useState<RoundSaved | null>(null);
function set<K extends keyof InspectionFormValues>(
key: K,
value: InspectionFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
const typedWeather = key === "weather" || key === "temperature";
setValues((current) => ({
...current,
[key]: value,
...(typedWeather ? { weatherAuto: false } : {}),
}));
setDirty(true);
if (key === "colonyId") setColonyError(null);
if (key === "date") setDateError(null);
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createInspection>[0]) =>
createInspection(input, { idempotencyKey }),
onSuccess: (inspection) => {
const capture = useCaptureMutation({
create: createInspection,
queue: (input: CreateInspectionInput, idempotencyKey, ownerUserId) =>
queueInspectionWithPhotos(values.photoSessionId, input, idempotencyKey, ownerUserId),
failureMessage: "Durchsicht konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
void queryClient.invalidateQueries({
queryKey: ["colonies", inspection.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
router.push(`/colonies/${inspection.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Durchsicht konnte nicht gespeichert werden.",
),
);
return;
}
void queueInspection(variables, idempotencyKey).then(() => {
draft.clear();
const savedOffline = outcome.via === "queued";
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
} else {
void queryClient.invalidateQueries({
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
void sendPhotosOf(values.photoSessionId, outcome.result.id, userId).catch(() =>
toast.error("Fotos konnten nicht gesendet werden."),
);
}
const advanced = stand.round ? stand.markDone(input.colonyId) : null;
if (advanced) {
setRoundSaved({ round: advanced, savedOffline });
return;
}
router.push(`/colonies/${input.colonyId}`);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
if (!values.colonyId) {
setColonyError("Bitte ein Volk auswählen.");
document.getElementById("inspection-colony")?.focus();
@@ -104,22 +289,130 @@ export default function NewInspectionPage() {
document.getElementById("inspection-date")?.focus();
return;
}
mutation.mutate(toInspectionInput(values));
capture.submit(toInspectionInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
// A draft from before photos existed has no session: the form keeps its own.
const photoSessionId = restored.photoSessionId ?? values.photoSessionId;
if (photoSessionId !== values.photoSessionId) {
// Like the typed values, photos taken before restoring give way to the
// restored entry. Unsaved photos left behind are purged after 3 days.
void discardSessionPhotos(values.photoSessionId).catch(() => undefined);
}
// A draft from before these fields existed lacks them.
setValues({
...initialInspectionValues(restored.colonyId),
...restored,
photoSessionId,
weatherAuto: restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""),
});
setDirty(true);
}
}
/** „Verwerfen" of an earlier draft takes the photos taken in it along. */
function discardDraft() {
const draftPhotoSessionId = draft.pending?.values.photoSessionId;
draft.discard();
if (draftPhotoSessionId) {
// Unsaved photos left behind are purged after 3 days anyway.
void discardSessionPhotos(draftPhotoSessionId).catch(() => undefined);
}
}
function takeOver(field: TakeoverField, value: NonNullable<ReturnType<typeof previousValueOf>>) {
setValues((current) => ({ ...current, [field]: value }));
setDirty(true);
}
function takeOverEverything() {
if (!last) return;
const { values: next, taken } = takeOverAll(values, last);
if (taken.length === 0) {
toast.info("Nichts zu übernehmen – alles schon ausgefüllt.");
return;
}
const before = values;
setValues(next);
setDirty(true);
toast.success(`${taken.length} Werte übernommen`, {
action: {
label: "Rückgängig",
onClick: () => setValues((current) => undoTakeOver(current, before, next, taken)),
},
duration: UNDO_TOAST_MS,
});
}
/** "Letztes Mal" for one field, or null if there is nothing new to offer. */
function previousFor(field: TakeoverField) {
if (!last) return null;
const previous = previousValueOf(last, field);
if (previous === null || values[field] === previous) return null;
return {
valueLabel: previousLabel(field, previous),
onTakeOver: () => takeOver(field, previous),
};
}
function hintFor(field: TakeoverField) {
const previous = previousFor(field);
return previous ? (
<PreviousValueHint fieldLabel={TAKEOVER_LABELS[field]} {...previous} />
) : null;
}
if (roundSaved) {
const { round } = roundSaved;
const next = stand.next(round);
const finishRound = () => {
stand.end();
router.push(`/apiaries/${encodeURIComponent(round.apiaryId)}`);
};
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<RoundNextPanel
next={next}
doneCount={round.doneColonyIds.length}
savedOffline={roundSaved.savedOffline}
onNext={() =>
next &&
router.push(
`/inspections/new?colonyId=${encodeURIComponent(next.id)}&round=${encodeURIComponent(round.apiaryId)}`,
)
}
onEnd={() => {
if (next) {
toast.info(
`Runde beendet – ${round.doneColonyIds.length} von ${round.colonies.length} Völkern`,
);
}
finishRound();
}}
/>
</main>
);
}
// Same rule as the backend automation: only an explicit "Nein" counts.
const noQueenSigns = values.queenSeen === false && values.eggsPresent === false;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
{stand.round ? (
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
{roundChipText(stand.round, values.colonyId)}
</p>
) : null}
<PageHeader
icon={ClipboardCheck}
title="Neue Durchsicht"
@@ -130,7 +423,7 @@ export default function NewInspectionPage() {
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
onDiscard={discardDraft}
/>
) : null}
<Card className="max-w-lg">
@@ -161,6 +454,7 @@ export default function NewInspectionPage() {
/>
)}
</Field>
{last ? <LastInspectionBar last={last} onTakeOverAll={takeOverEverything} /> : null}
</FormSection>
<FormSection
@@ -175,6 +469,7 @@ export default function NewInspectionPage() {
label={observation.label}
value={values[observation.key]}
onChange={(answer) => set(observation.key, answer)}
previous={previousFor(observation.key)}
/>
))}
</div>
@@ -190,26 +485,34 @@ export default function NewInspectionPage() {
) : null}
<Field label="Brutbild" group>
{({ id, labelId, describedBy }) => (
<RatingScale
id={id}
labelledBy={labelId}
describedBy={describedBy}
value={values.broodPattern}
onChange={(rating) => set("broodPattern", rating)}
lowLabel="lückenhaft"
highLabel="geschlossen"
/>
<>
<RatingScale
id={id}
labelledBy={labelId}
describedBy={describedBy}
value={values.broodPattern}
onChange={(rating) => set("broodPattern", rating)}
lowLabel="lückenhaft"
highLabel="geschlossen"
/>
{hintFor("broodPattern")}
</>
)}
</Field>
<Field label="Brutmenge" hint="z. B. „4 Waben“">
<Field label="Brutwaben" hint="Waben mit Brut (offen oder verdeckelt)">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.broodAmount}
onChange={(event) => set("broodAmount", event.target.value)}
/>
<>
<NumberStepper
id={id}
value={values.broodFrames}
onChange={(count) => set("broodFrames", count)}
unitLabel="Waben mit Brut"
decrementLabel="Eine Brutwabe weniger"
incrementLabel="Eine Brutwabe mehr"
describedBy={describedBy}
/>
{hintFor("broodFrames")}
</>
)}
</Field>
</FormSection>
@@ -217,54 +520,64 @@ export default function NewInspectionPage() {
<FormSection step={3} title="Stärke & Verhalten">
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.colonyStrength}
onChange={(event) =>
set("colonyStrength", event.target.value)
}
/>
<>
<NumberStepper
id={id}
value={values.colonyStrength}
onChange={(count) => set("colonyStrength", count)}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
describedBy={describedBy}
/>
{hintFor("colonyStrength")}
</>
)}
</Field>
<Field label="Sanftmut" group>
{({ id, labelId }) => (
<RatingScale
id={id}
labelledBy={labelId}
value={values.gentleness}
onChange={(rating) => set("gentleness", rating)}
lowLabel="stechlustig"
highLabel="sehr sanft"
/>
<>
<RatingScale
id={id}
labelledBy={labelId}
value={values.gentleness}
onChange={(rating) => set("gentleness", rating)}
lowLabel="stechlustig"
highLabel="sehr sanft"
/>
{hintFor("gentleness")}
</>
)}
</Field>
<Field label="Wabensitz" group>
{({ id, labelId }) => (
<RatingScale
id={id}
labelledBy={labelId}
value={values.combBehavior}
onChange={(rating) => set("combBehavior", rating)}
lowLabel="läuft weg"
highLabel="sitzt ruhig"
/>
<>
<RatingScale
id={id}
labelledBy={labelId}
value={values.combBehavior}
onChange={(rating) => set("combBehavior", rating)}
lowLabel="läuft weg"
highLabel="sitzt ruhig"
/>
{hintFor("combBehavior")}
</>
)}
</Field>
<Field label="Schwarmstimmung" group>
{({ id, labelId }) => (
<RatingScale
id={id}
labelledBy={labelId}
value={values.swarmMood}
onChange={(rating) => set("swarmMood", rating)}
lowLabel="keine"
highLabel="stark"
/>
<>
<RatingScale
id={id}
labelledBy={labelId}
value={values.swarmMood}
onChange={(rating) => set("swarmMood", rating)}
direction="lower-is-better"
lowLabel="ruhig"
highLabel="stark"
/>
{hintFor("swarmMood")}
</>
)}
</Field>
</FormSection>
@@ -272,25 +585,29 @@ export default function NewInspectionPage() {
<FormSection step={4} title="Futter">
<Field label="Futterstatus" group>
{({ id, labelId }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
options={FEED_STATUS_OPTIONS}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
<>
<ChoiceGroup
id={id}
labelledBy={labelId}
options={FEED_STATUS_OPTIONS}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
{hintFor("feedStatus")}
</>
)}
</Field>
</FormSection>
<FormSection
step={5}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={7}
title="Wetter"
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:hidden">
{weatherNotice}
</p>
<Field label="Wetter">
{({ id }) => (
<Input
@@ -313,16 +630,28 @@ export default function NewInspectionPage() {
/>
)}
</Field>
</FormSection>
<FormSection
step={6}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={5}
>
<Field label="Honigraum">
{({ id }) => (
<Input
id={id}
className="h-11"
value={values.honeySuperStatus}
onChange={(event) =>
set("honeySuperStatus", event.target.value)
}
/>
<>
<Input
id={id}
className="h-11"
value={values.honeySuperStatus}
onChange={(event) =>
set("honeySuperStatus", event.target.value)
}
/>
{hintFor("honeySuperStatus")}
</>
)}
</Field>
<Field label="Varroa-Befund">
@@ -369,14 +698,21 @@ export default function NewInspectionPage() {
</Field>
</FormSection>
<PhotoCaptureField
step={7}
sessionId={values.photoSessionId}
date={values.date}
onChange={() => setDirty(true)}
/>
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -384,9 +720,9 @@ export default function NewInspectionPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending
{capture.isPending
? "Wird gespeichert…"
: "Durchsicht speichern"}
</Button>
@@ -397,3 +733,23 @@ export default function NewInspectionPage() {
</main>
);
}
export default function NewInspectionPage() {
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const colonyId = searchParams.get("colonyId") ?? "";
const round = searchParams.get("round");
// A new colony or round remounts the form: fresh entry, fresh idempotency key.
return (
<InspectionCaptureForm
key={`${colonyId}:${round ?? ""}`}
initialColonyId={colonyId}
roundApiaryId={round}
backTarget={backTarget}
/>
);
}
+60 -2
View File
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Card, CardContent } from "@/components/ui/card";
import { StickySubmit } from "@/components/form/sticky-submit";
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
import AppLayout from "./layout";
vi.mock("next/navigation", () => ({
@@ -27,8 +28,24 @@ vi.mock("@/lib/auth/auth-context", () => ({
}));
vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null }));
vi.mock("@/components/offline-sync-badge", () => ({ OfflineSyncBadge: () => null }));
vi.mock("@/components/offline/offline-band", () => ({
OfflineBand: ({
userId,
pageContentRef,
}: {
userId: string;
pageContentRef: { current: HTMLElement | null };
}) => (
<div data-testid="offline-band">
{userId}
<button type="button" onClick={() => pageContentRef.current?.focus()}>
Fokus auf den Seiteninhalt
</button>
</div>
),
}));
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
vi.mock("@/lib/photos/store", () => ({ purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0) }));
// A box with overflow auto/scroll/hidden is a scroll container. Any such
// ancestor of the page content captures `position: sticky` (StickySubmit),
@@ -42,6 +59,47 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
}
describe("AppLayout", () => {
it("shows the offline band for the signed-in user right below the header", () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
const band = screen.getByTestId("offline-band");
expect(band).toHaveTextContent("u1");
expect(band.previousElementSibling?.tagName).toBe("HEADER");
});
it("gives the band the page content wrapper as a stable focus target", () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
fireEvent.click(screen.getByRole("button", { name: "Fokus auf den Seiteninhalt" }));
const wrapper = screen.getByText("Seiteninhalt").closest("[tabindex='-1']");
expect(wrapper).not.toBeNull();
expect(wrapper).toHaveFocus();
expect(wrapper?.previousElementSibling).toBe(screen.getByTestId("offline-band"));
});
it("deletes photos of form drafts older than 3 days when the app starts", async () => {
vi.mocked(purgeStaleDraftPhotos).mockClear();
const before = Date.now();
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
await waitFor(() => expect(purgeStaleDraftPhotos).toHaveBeenCalledTimes(1));
const [now] = vi.mocked(purgeStaleDraftPhotos).mock.calls[0];
expect(now).toBeGreaterThanOrEqual(before);
});
it("does not wrap the page content in its own scroll container", () => {
render(
<AppLayout>
+21 -7
View File
@@ -1,12 +1,13 @@
"use client";
import { ReactNode, useEffect } from "react";
import { ReactNode, useEffect, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
import { NotificationBell } from "@/components/notification-bell";
import { OfflineSyncBadge } from "@/components/offline-sync-badge";
import { OfflineBand } from "@/components/offline/offline-band";
import { UserAvatar } from "@/components/user-avatar";
import {
DropdownMenu,
@@ -19,12 +20,14 @@ import {
} from "@/components/ui/dropdown-menu";
import { Sidebar } from "./sidebar";
import { BottomNav } from "./bottom-nav";
import { QuickAddMenu } from "./quick-add-menu";
import { QuickAddSheet } from "./quick-add-sheet";
import { PageTransition } from "./page-transition";
export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const { user, isLoading, logout } = useAuth();
// Focus lands here when the offline queue sheet closes and nothing else is left.
const pageContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isLoading && !user) {
@@ -32,6 +35,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
}
}, [isLoading, user, router]);
// Photos of form drafts nobody can restore any more (older than 3 days)
// leave the device. Housekeeping only: if it fails, the next start tries again.
useEffect(() => {
void purgeStaleDraftPhotos(Date.now()).catch(() => undefined);
}, []);
if (isLoading || !user) {
return null;
}
@@ -57,7 +66,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
ApiCore
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
<OfflineSyncBadge />
<QuickAddSheet user={user} variant="header" />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full">
@@ -85,12 +94,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</DropdownMenu>
</div>
</header>
<div className="flex-1 pb-(--bottom-nav-height) sm:pb-(--safe-area-bottom)">
<OfflineBand userId={user.id} pageContentRef={pageContentRef} />
<div
ref={pageContentRef}
tabIndex={-1}
className="flex-1 pb-(--bottom-nav-height) outline-none sm:pb-(--safe-area-bottom)"
>
<PageTransition>{children}</PageTransition>
</div>
</div>
<BottomNav />
<QuickAddMenu />
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
</div>
);
}
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MoreSheet } from "./more-sheet";
vi.mock("next/navigation", () => ({
usePathname: () => "/dashboard",
}));
describe("MoreSheet", () => {
it('„Mehr" opens a sheet listing Honigchargen with 56 px rows', async () => {
render(<MoreSheet user={{ permissions: [] }} />);
await userEvent.click(screen.getByRole("button", { name: "Mehr" }));
const link = await screen.findByRole("link", { name: "Honigchargen" });
expect(link).toHaveAttribute("href", "/honey-batches");
expect(link).toHaveClass("min-h-14");
});
it("lists only what the user may see", async () => {
render(<MoreSheet user={{ permissions: [] }} />);
await userEvent.click(screen.getByRole("button", { name: "Mehr" }));
await screen.findByRole("link", { name: "Honigchargen" });
expect(screen.queryByRole("link", { name: "Audit-Log" })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu } from "lucide-react";
import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import type { AuthUser } from "@/lib/auth/auth-context";
import { BOTTOM_NAV_HREFS, isNavActive, visibleNavGroups } from "./nav-config";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
/**
* The fifth bottom-nav entry: opens every area the user may see, grouped like
* the desktop sidebar. Rows are 56px high for gloved or hurried thumbs.
*/
export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const groups = visibleNavGroups(user);
// „Mehr" is the current place when the open page is not one of the four
// favourites next to it.
const isMoreActive =
!BOTTOM_NAV_HREFS.some((href) => isNavActive(pathname, href)) &&
groups.some((group) =>
group.items.some((item) => isNavActive(pathname, item.href)),
);
return (
<NavSheet
open={open}
onOpenChange={setOpen}
title="Mehr"
trigger={
<SheetTrigger
className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
isMoreActive ? "text-primary" : "text-muted-foreground",
)}
>
<Menu className="size-5" aria-hidden="true" />
Mehr
</SheetTrigger>
}
>
<nav aria-label="Alle Bereiche" className="flex flex-col gap-4 px-2 pb-4">
{groups.map((group, index) => (
<div key={group.label ?? `group-${index}`} className="flex flex-col">
{group.label ? (
<p className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground/80 uppercase">
{group.label}
</p>
) : null}
{group.items.map((item) => {
const Icon = item.icon;
const isActive = isNavActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
onClick={() => setOpen(false)}
className={cn(
NAV_SHEET_ROW_CLASS,
isActive
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-accent/70",
)}
>
<Icon className="size-6 shrink-0" aria-hidden="true" />
{item.label}
</Link>
);
})}
</div>
))}
</nav>
</NavSheet>
);
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
ADMIN_GROUP,
BOTTOM_NAV_HREFS,
NAV_GROUPS,
visibleNavGroups,
} from "./nav-config";
const ALL_ITEMS = [...NAV_GROUPS, ADMIN_GROUP].flatMap((group) => group.items);
const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) =>
item.permission === undefined ? [] : [item.permission].flat(),
);
function hrefs(permissions: string[]): string[] {
return visibleNavGroups({ permissions })
.flatMap((group) => group.items)
.map((item) => item.href);
}
describe("nav-config", () => {
it('every sidebar area is reachable on a phone via bottom nav or „Mehr"', () => {
const sidebar = hrefs(ALL_PERMISSIONS);
// „Mehr" lists every visible group, the bottom nav the four favourites.
const phone = new Set([...BOTTOM_NAV_HREFS, ...sidebar]);
expect(sidebar.length).toBe(ALL_ITEMS.length);
expect(sidebar.every((href) => phone.has(href))).toBe(true);
expect(sidebar).toContain("/honey-batches");
});
it("keeps every bottom nav entry in the nav config", () => {
const known = ALL_ITEMS.map((item) => item.href);
expect(BOTTOM_NAV_HREFS.every((href) => known.includes(href))).toBe(true);
});
it("admin entries need their permission", () => {
expect(hrefs([])).not.toContain("/settings/audit-log");
expect(hrefs([])).not.toContain("/settings/backup");
expect(visibleNavGroups({ permissions: [] }).map((g) => g.label)).not.toContain(
"Verwaltung",
);
const admin = visibleNavGroups({ permissions: ["audit:read"] }).find(
(group) => group.label === "Verwaltung",
);
expect(admin?.items.map((item) => item.href)).toEqual(["/settings/audit-log"]);
});
it("hides items the user may not see", () => {
expect(hrefs([])).not.toContain("/winter");
expect(hrefs(["winter-checks:read"])).toContain("/winter");
});
});
@@ -0,0 +1,193 @@
import {
BarChart3,
Boxes,
CalendarDays,
Crown,
Database,
Droplet,
Factory,
FlaskConical,
History,
Layers,
LayoutDashboard,
ListTodo,
type LucideIcon,
MapPin,
Package,
Scissors,
Search,
ShoppingBag,
Snowflake,
Users,
Warehouse,
Wrench,
Zap,
} from "lucide-react";
import type { AuthUser } from "@/lib/auth/auth-context";
/**
* The single source of truth for every area of the app: the desktop sidebar,
* the phone bottom nav and its „Mehr" sheet all read from here, so an area
* can never be reachable on one screen size and missing on another.
*/
export interface NavItem {
label: string;
/** Short name for the cramped bottom nav. */
shortLabel?: string;
href: string;
icon: LucideIcon;
// Any one of these grants visibility - used for hub pages that fold
// together several previously-separate settings pages, each gated by its
// own permission.
permission?: string | string[];
}
export interface NavGroup {
label: string | null;
items: NavItem[];
}
export const NAV_GROUPS: NavGroup[] = [
{
label: null,
items: [
{
label: "Dashboard",
shortLabel: "Start",
href: "/dashboard",
icon: LayoutDashboard,
},
{ label: "Suche", href: "/search", icon: Search },
],
},
{
label: "Betrieb",
items: [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
{
label: "Überwinterung",
href: "/winter",
icon: Snowflake,
permission: "winter-checks:read",
},
],
},
{
label: "Arbeit",
items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
],
},
{
label: "Ernte & Lager",
items: [
{ label: "Ernten", href: "/harvests", icon: Scissors },
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package },
{
label: "Rohstoffe",
href: "/raw-materials",
icon: Boxes,
permission: "raw-material:read",
},
{
label: "Herstellungschargen",
href: "/production-batches",
icon: Factory,
permission: "production-batch:read",
},
{ label: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench },
],
},
{
label: "Auswertung",
items: [{ label: "Statistik", href: "/statistics", icon: BarChart3 }],
},
];
export const ADMIN_GROUP: NavGroup = {
label: "Verwaltung",
items: [
{
label: "Benutzer & Rollen",
href: "/settings/users-roles",
icon: Users,
permission: ["users:read", "roles:read"],
},
{
label: "Automatisierung",
href: "/settings/automation",
icon: Zap,
permission: [
"webhooks:read",
"automation:read",
"task-templates:read",
"label-templates:read",
"site-settings:read",
"calendar:configure",
"api-keys:manage",
],
},
{
label: "Backup",
href: "/settings/backup",
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"],
},
{
label: "Audit-Log",
href: "/settings/audit-log",
icon: History,
permission: "audit:read",
},
],
};
/** The four favourites on the phone; everything else sits behind „Mehr". */
export const BOTTOM_NAV_HREFS = [
"/dashboard",
"/colonies",
"/tasks",
"/search",
] as const;
function isVisible(
user: Pick<AuthUser, "permissions">,
permission: NavItem["permission"],
): boolean {
if (!permission) {
return true;
}
const required = Array.isArray(permission) ? permission : [permission];
return required.some((key) => user.permissions.includes(key));
}
/** All groups the user may see, without empty ones; admin group last. */
export function visibleNavGroups(user: Pick<AuthUser, "permissions">): NavGroup[] {
return [...NAV_GROUPS, ADMIN_GROUP]
.map((group) => ({
...group,
items: group.items.filter((item) => isVisible(user, item.permission)),
}))
.filter((group) => group.items.length > 0);
}
/** The bottom nav entries, resolved from the config in their fixed order. */
export function bottomNavItems(): NavItem[] {
const all = NAV_GROUPS.flatMap((group) => group.items);
return BOTTOM_NAV_HREFS.flatMap((href) => all.filter((item) => item.href === href));
}
/**
* Whether a nav entry is the current area. Matches on a path boundary, so
* `/queens` does not light up for `/queens-archive`.
*/
export function isNavActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
@@ -63,3 +63,50 @@ describe("NewProductionBatchPage – consumed raw materials", () => {
expect(middle).not.toBeInTheDocument();
});
});
describe("NewProductionBatchPage – leaving", () => {
beforeEach(() => {
vi.mocked(fetchRawMaterialLots).mockResolvedValue([LOT] as never);
vi.mocked(fetchProducts).mockResolvedValue([]);
});
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewProductionBatchPage />
</QueryClientProvider>,
);
}
it("lets an untouched form go back without asking", () => {
renderPage();
expect(screen.getByRole("link", { name: /Zu den Herstellungschargen/ })).toBeInTheDocument();
expect(screen.queryByText("Formular verlassen?")).not.toBeInTheDocument();
});
it("asks before leaving once a raw material row was added", async () => {
renderPage();
const addRow = await screen.findByRole("button", { name: "+ Rohstoff hinzufügen" });
await vi.waitFor(() => expect(addRow).toBeEnabled());
await userEvent.click(addRow);
await userEvent.click(screen.getByRole("link", { name: /Zu den Herstellungschargen/ }));
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
});
it("still asks after removing one of two rows", async () => {
renderPage();
const addRow = await screen.findByRole("button", { name: "+ Rohstoff hinzufügen" });
await vi.waitFor(() => expect(addRow).toBeEnabled());
await userEvent.click(addRow);
await userEvent.click(addRow);
await userEvent.click(screen.getByRole("button", { name: "Rohstoff 1 entfernen" }));
await userEvent.click(screen.getByRole("link", { name: /Zu den Herstellungschargen/ }));
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
});
});
@@ -12,6 +12,7 @@ import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -34,7 +35,8 @@ export default function NewProductionBatchPage() {
const queryClient = useQueryClient();
const [productId, setProductId] = useState("");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(todayLocalIsoDate());
const [initialDate] = useState(todayLocalIsoDate);
const [date, setDate] = useState(initialDate);
const [quantity, setQuantity] = useState("");
const [bestBeforeDate, setBestBeforeDate] = useState("");
const [notes, setNotes] = useState("");
@@ -60,6 +62,19 @@ export default function NewProductionBatchPage() {
const removeInput = (key: string) =>
setInputs((current) => current.filter((row) => row.key !== key));
const [error, setError] = useState<string | null>(null);
// No autosaved draft here, so leaving asks whenever the entry differs from
// the untouched form. Derived from the values: custom selects and the row
// buttons do not fire a form change event. `touched` only supplements.
const [touched, setTouched] = useState(false);
const dirty =
touched ||
productId !== "" ||
lotNumber !== "" ||
date !== initialDate ||
quantity !== "" ||
bestBeforeDate !== "" ||
notes !== "" ||
inputs.length > 0;
const { data: products } = useQuery({
queryKey: ["products", { status: "ACTIVE" }],
@@ -111,6 +126,11 @@ export default function NewProductionBatchPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/production-batches"
label="Zu den Herstellungschargen"
dirty={dirty}
/>
<PageHeader
icon={Factory}
title="Neue Herstellungscharge"
@@ -120,6 +140,7 @@ export default function NewProductionBatchPage() {
<CardContent>
<form
className="flex max-w-xl flex-col gap-4"
onChange={() => setTouched(true)}
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -85,6 +86,12 @@ export default function NewProductPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/products"
label="Zu den Produkten"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={ShoppingBag}
title="Neues Produkt"
@@ -10,6 +10,7 @@ import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { RecordSelect } from "@/components/form/record-select";
@@ -113,6 +114,12 @@ export default function NewQueenPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/queens"
label="Zu den Königinnen"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Crown}
title="Neue Königin"
@@ -0,0 +1,63 @@
import {
Bug,
ClipboardCheck,
ListTodo,
type LucideIcon,
Scissors,
Sprout,
Syringe,
Utensils,
} from "lucide-react";
export interface QuickAction {
label: string;
href: string;
icon: LucideIcon;
/** Creating needs this permission; actions without it are not offered. */
permission: string;
}
export const QUICK_ACTIONS: readonly QuickAction[] = [
{
label: "Durchsicht",
href: "/inspections/new",
icon: ClipboardCheck,
permission: "inspections:create",
},
{
label: "Varroa-Messung",
href: "/varroa/new",
icon: Bug,
permission: "varroa:create",
},
{
label: "Schwarmkontrolle",
href: "/swarm-control/new",
icon: Sprout,
permission: "swarm-control:create",
},
{
label: "Behandlung",
href: "/treatments/new",
icon: Syringe,
permission: "treatments:create",
},
{
label: "Fütterung",
href: "/feedings/new",
icon: Utensils,
permission: "feedings:create",
},
{
label: "Aufgabe",
href: "/tasks/new",
icon: ListTodo,
permission: "tasks:create",
},
{
label: "Ernte",
href: "/harvests/new",
icon: Scissors,
permission: "harvests:create",
},
];
@@ -1,38 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QuickAddMenu } from "./quick-add-menu";
let mockPathname = "/dashboard";
vi.mock("next/navigation", () => ({
usePathname: () => mockPathname,
}));
describe("QuickAddMenu", () => {
beforeEach(() => {
mockPathname = "/dashboard";
});
it.each(["/dashboard", "/colonies", "/colonies/abc", "/news", "/tasks/renewal"])(
"offers the quick-add button on %s",
(pathname) => {
mockPathname = pathname;
render(<QuickAddMenu />);
expect(screen.getByRole("button", { name: "Neu" })).toBeInTheDocument();
},
);
it.each([
"/inspections/new",
"/settings/label-templates/new",
"/colonies/abc/edit",
"/colonies/new/",
])("hides the quick-add button on the form route %s so it cannot cover the save bar", (pathname) => {
mockPathname = pathname;
render(<QuickAddMenu />);
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
});
});
@@ -1,62 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const QUICK_ACTIONS = [
{ label: "Durchsicht", href: "/inspections/new" },
{ label: "Varroa-Messung", href: "/varroa/new" },
{ label: "Schwarmkontrolle", href: "/swarm-control/new" },
{ label: "Behandlung", href: "/treatments/new" },
{ label: "Fütterung", href: "/feedings/new" },
{ label: "Aufgabe", href: "/tasks/new" },
{ label: "Ernte", href: "/harvests/new" },
];
// Create and edit forms end in a save bar (StickySubmit) right where this
// button floats. Deciding by route rather than letting StickySubmit register
// itself in a context is known on the first render (no flash, no layout
// shift), needs no client state in StickySubmit and also covers form routes
// without a sticky bar. Mid-form, jumping to another new form is no quick
// action anyway.
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
export function QuickAddMenu() {
const pathname = usePathname();
if (FORM_ROUTE.test(pathname)) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon-lg"
// size-12: 48px touch target for gloved hands (icon-lg is 36px).
className="fixed right-4 bottom-[calc(var(--bottom-nav-height)+--spacing(4))] z-40 size-12 rounded-full shadow-lg sm:hidden"
aria-label="Neu"
/>
}
>
<Plus className="size-6" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top">
{QUICK_ACTIONS.map((action) => (
<DropdownMenuItem key={action.href} render={<Link href={action.href} />}>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QUICK_ACTIONS } from "./quick-actions";
import { QuickAddSheet } from "./quick-add-sheet";
let mockPathname = "/dashboard";
vi.mock("next/navigation", () => ({
usePathname: () => mockPathname,
}));
const ALL = QUICK_ACTIONS.map((action) => action.permission);
describe("QuickAddSheet", () => {
beforeEach(() => {
mockPathname = "/dashboard";
});
it("lists actions with icons as links", async () => {
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
const link = await screen.findByRole("link", { name: "Durchsicht" });
expect(link).toHaveAttribute("href", "/inspections/new");
expect(link).toHaveClass("min-h-14");
expect(link.querySelector("svg.size-6")).not.toBeNull();
expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length);
});
it("hides Behandlung without treatments:create", async () => {
render(
<QuickAddSheet
user={{ permissions: ALL.filter((p) => p !== "treatments:create") }}
variant="fab"
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
await screen.findByRole("link", { name: "Durchsicht" });
expect(screen.queryByRole("link", { name: "Behandlung" })).not.toBeInTheDocument();
});
it("renders nothing when no action is allowed", () => {
render(<QuickAddSheet user={{ permissions: [] }} variant="fab" />);
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
});
it.each([
"/inspections/new",
"/settings/label-templates/new",
"/colonies/abc/edit",
"/colonies/new/",
])("is not rendered on the form route %s", (pathname) => {
mockPathname = pathname;
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
});
it.each(["/dashboard", "/colonies/abc", "/news", "/tasks/renewal"])(
"offers the button on %s",
(pathname) => {
mockPathname = pathname;
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
expect(screen.getByRole("button", { name: "Neu" })).toBeInTheDocument();
},
);
it("offers a header button on desktop", () => {
render(<QuickAddSheet user={{ permissions: ALL }} variant="header" />);
const button = screen.getByRole("button", { name: /Neu/ });
expect(button).toHaveClass("sm:inline-flex");
expect(button).toHaveClass("hidden");
});
});
@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
import type { AuthUser } from "@/lib/auth/auth-context";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
import { QUICK_ACTIONS } from "./quick-actions";
// Create and edit forms end in a save bar (StickySubmit) right where the
// phone button floats. Deciding by route rather than letting StickySubmit
// register itself in a context is known on the first render (no flash, no
// layout shift), needs no client state in StickySubmit and also covers form
// routes without a sticky bar. Mid-form, jumping to another new form is no
// quick action anyway - and the header button hides with it for consistency.
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
interface QuickAddSheetProps {
user: Pick<AuthUser, "permissions">;
/** "fab" floats above the phone bottom nav, "header" sits in the desktop header. */
variant: "fab" | "header";
}
/** „+ Neu": the same icon sheet on the phone (FAB) and on the desktop (header). */
export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const actions = QUICK_ACTIONS.filter((action) =>
user.permissions.includes(action.permission),
);
if (FORM_ROUTE.test(pathname) || actions.length === 0) {
return null;
}
return (
<NavSheet
open={open}
onOpenChange={setOpen}
title="Neu erfassen"
className="sm:mx-auto sm:max-w-md"
trigger={
variant === "fab" ? (
<SheetTrigger
render={
<Button
size="icon-lg"
// size-12: 48px touch target for gloved hands (icon-lg is 36px).
className="fixed right-4 bottom-[calc(var(--bottom-nav-height)+--spacing(4))] z-40 size-12 rounded-full shadow-lg sm:hidden"
aria-label="Neu"
/>
}
>
<Plus className="size-6" aria-hidden="true" />
</SheetTrigger>
) : (
<SheetTrigger
render={
<Button className="hidden min-h-11 gap-1.5 sm:inline-flex" />
}
>
<Plus className="size-4" aria-hidden="true" />
Neu
</SheetTrigger>
)
}
>
<nav aria-label="Neu erfassen" className="flex flex-col px-2 pb-4">
{actions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.href}
href={action.href}
onClick={() => setOpen(false)}
className={`${NAV_SHEET_ROW_CLASS} text-foreground hover:bg-accent/70`}
>
<Icon
className="size-6 shrink-0 text-primary"
aria-hidden="true"
/>
{action.label}
</Link>
);
})}
</nav>
</NavSheet>
);
}
@@ -0,0 +1,53 @@
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 NewRawMaterialPage from "./page";
import { fetchApiaries } from "@/lib/apiaries/api";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/raw-materials/api", () => ({ createRawMaterialLot: vi.fn() }));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewRawMaterialPage />
</QueryClientProvider>,
);
}
describe("NewRawMaterialPage – leaving", () => {
beforeEach(() => {
vi.mocked(fetchApiaries).mockResolvedValue([]);
});
it("lets an untouched form go back without asking", async () => {
renderPage();
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
expect(screen.queryByText("Formular verlassen?")).not.toBeInTheDocument();
});
it("asks before leaving after only a select value was changed", async () => {
renderPage();
await userEvent.click(screen.getByRole("combobox", { name: "Art" }));
const options = await screen.findAllByRole("option");
await userEvent.click(options[options.length - 1]);
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
});
it("asks before leaving after typing", async () => {
renderPage();
await userEvent.type(screen.getByLabelText("Menge (g)"), "100");
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ import { createRawMaterialLot } from "@/lib/raw-materials/api";
import { RAW_MATERIAL_KIND_LABELS } from "@/lib/raw-materials/labels";
import { RawMaterialKind } from "@/lib/raw-materials/types";
import { fetchApiaries } from "@/lib/apiaries/api";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -25,12 +26,25 @@ export default function NewRawMaterialPage() {
const queryClient = useQueryClient();
const [kind, setKind] = useState<RawMaterialKind>("BEESWAX");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(todayLocalIsoDate);
const [initialDate] = useState(todayLocalIsoDate);
const [date, setDate] = useState(initialDate);
const [quantity, setQuantity] = useState("");
// "" = "Kein Stand".
const [apiaryId, setApiaryId] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
// No autosaved draft here, so leaving asks whenever the entry differs from
// the untouched form. Derived from the values: custom selects do not fire
// a form change event. `touched` only supplements.
const [touched, setTouched] = useState(false);
const dirty =
touched ||
kind !== "BEESWAX" ||
lotNumber !== "" ||
date !== initialDate ||
quantity !== "" ||
apiaryId !== "" ||
notes !== "";
const { data: apiaries } = useQuery({
queryKey: ["apiaries"],
@@ -69,6 +83,11 @@ export default function NewRawMaterialPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/raw-materials"
label="Zu den Rohstoffen"
dirty={dirty}
/>
<PageHeader
icon={Boxes}
title="Neuer Rohstoff"
@@ -78,6 +97,7 @@ export default function NewRawMaterialPage() {
<CardContent>
<form
className="flex max-w-xl flex-col gap-4"
onChange={() => setTouched(true)}
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
@@ -0,0 +1,90 @@
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 { ApiKeysTab } from "./api-keys-tab";
import { createApiKey, fetchApiKeys } from "@/lib/api-keys/api";
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/api-keys/api", () => ({
fetchApiKeys: vi.fn(),
createApiKey: vi.fn(),
revokeApiKey: vi.fn(),
updateApiKeyAllowedIps: vi.fn(),
}));
const HINT = /Dieser Schlüssel darf lesen und buchen/;
function makeKey(id: string, scopes: ApiKeyScope[]): ApiKey {
return {
id,
name: `Schlüssel ${id}`,
prefix: `ack_${id}`,
scopes,
createdByUserId: null,
createdAt: "2026-10-01T00:00:00.000Z",
lastUsedAt: null,
lastUsedIp: null,
allowedIps: [],
expiresAt: null,
revokedAt: null,
};
}
function renderTab() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ApiKeysTab />
</QueryClientProvider>,
);
}
describe("ApiKeysTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows the separation hint only for a key with both scopes", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("both", ["shop-sync:read", "shop-sync:write"]),
makeKey("read", ["shop-sync:read"]),
]);
renderTab();
const hints = await screen.findAllByRole("status");
expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent(HINT);
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
});
it("shows no hint when every key has one scope", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([makeKey("read", ["shop-sync:read"])]);
renderTab();
await screen.findByText("Schlüssel read");
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
});
it("creates a read key by default", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:read"]), token: "t" });
renderTab();
await userEvent.click(await screen.findByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:read"]);
});
it('creates a write key when "Buchen" is chosen', async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:write"]), token: "t" });
renderTab();
await userEvent.click(await screen.findByRole("radio", { name: "Buchen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
});
});
@@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, KeyRound, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import { API_KEY_SCOPE_LABELS, ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import { API_KEY_SCOPE_LABELS, hasCombinedShopScopes } from "@/lib/api-keys/scopes";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,7 +15,13 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
// Reading and booking are separate keys, so a leaked key can only do one.
const SCOPE_CHOICE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Lesen",
"shop-sync:write": "Buchen",
};
const SCOPE_OPTIONS = choiceOptions(SCOPE_CHOICE_LABELS);
const SCOPE_LABEL_ID = "api-key-scope-label";
function formatDate(value: string | null): string {
return value
@@ -38,6 +46,7 @@ export function ApiKeysTab() {
const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState("");
const [allowedIps, setAllowedIps] = useState("");
const [scope, setScope] = useState<ApiKeyScope>("shop-sync:read");
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
const [copied, setCopied] = useState(false);
@@ -75,7 +84,7 @@ export function ApiKeysTab() {
const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({
name: name.trim(),
scopes: DEFAULT_SCOPES,
scopes: [scope],
expiresInDays: days,
allowedIps: parseAllowlist(allowedIps),
});
@@ -139,6 +148,16 @@ export function ApiKeysTab() {
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex w-56 flex-col gap-1.5">
<Label id={SCOPE_LABEL_ID}>Berechtigung</Label>
<ChoiceGroup
options={SCOPE_OPTIONS}
value={scope}
onChange={setScope}
labelledBy={SCOPE_LABEL_ID}
className="grid-cols-2"
/>
</div>
<div className="flex w-44 flex-col gap-1.5">
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
<Input
@@ -170,7 +189,7 @@ export function ApiKeysTab() {
</Button>
</form>
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}. IP-Adressen mit Komma trennen; leer
Berechtigung: {API_KEY_SCOPE_LABELS[scope]}. Lesen und Buchen bekommen je einen eigenen Schlüssel. IP-Adressen mit Komma trennen; leer
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ –
die zuletzt benutzte Adresse lässt sich übernehmen.
</p>
@@ -200,6 +219,20 @@ export function ApiKeysTab() {
<Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge>
</span>
<span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</span>
<span className="flex flex-wrap gap-1.5">
{apiKey.scopes.map((keyScope) => (
<Badge key={keyScope} variant="secondary">
{API_KEY_SCOPE_LABELS[keyScope]}
</Badge>
))}
</span>
{state.active && hasCombinedShopScopes(apiKey) ? (
<p role="status" className="text-xs text-muted-foreground">
Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte
Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in
Wabenhain eintragen, dann diesen widerrufen.
</p>
) : null}
<span className="text-xs text-muted-foreground">
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
{formatDate(apiKey.lastUsedAt)}
@@ -0,0 +1,70 @@
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 { SocialMediaTab } from "./social-media-tab";
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
import type { SiteSettings } from "@/lib/site-settings/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/site-settings/api", () => ({
fetchSiteSettings: vi.fn(),
updateSiteSettings: vi.fn(),
}));
const settings: SiteSettings = {
id: "singleton",
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
websiteUrl: null,
holderName: null,
holderAddress: null,
originCountry: "Deutschland",
updatedAt: "2026-10-13T00:00:00.000Z",
};
function renderTab() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<SocialMediaTab />
</QueryClientProvider>,
);
}
describe("SocialMediaTab origin country", () => {
beforeEach(() => {
vi.mocked(fetchSiteSettings).mockReset();
vi.mocked(updateSiteSettings).mockReset();
vi.mocked(fetchSiteSettings).mockResolvedValue(settings);
vi.mocked(updateSiteSettings).mockResolvedValue(settings);
});
it("shows the saved country with its hint", async () => {
renderTab();
expect(await screen.findByLabelText(/^Herkunftsland/)).toHaveValue("Deutschland");
expect(screen.getByText(/Erscheint auf der Herkunftsseite/)).toBeInTheDocument();
});
it("saves a changed country trimmed", async () => {
renderTab();
const input = await screen.findByLabelText(/^Herkunftsland/);
await userEvent.clear(input);
await userEvent.type(input, " Österreich ");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({
originCountry: "Österreich",
});
});
it("does not save an emptied country (the server cannot hold none)", async () => {
renderTab();
await userEvent.clear(await screen.findByLabelText(/^Herkunftsland/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateSiteSettings).not.toHaveBeenCalled();
expect(await screen.findByText("Bitte ein Land angeben.")).toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
// Mounted only once `settings` has arrived, so its useState initial values
// already reflect the fetched data - no synchronization effect needed.
@@ -20,6 +21,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
const [holderName, setHolderName] = useState(settings.holderName ?? "");
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
const [originCountry, setOriginCountry] = useState(settings.originCountry);
const [originCountryError, setOriginCountryError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: updateSiteSettings,
@@ -32,6 +35,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// NOT NULL in the database: it can be changed, not emptied.
if (originCountry.trim().length < 2) {
setOriginCountryError("Bitte ein Land angeben.");
return;
}
mutation.mutate({
youtubeUrl,
instagramUrl,
@@ -39,6 +47,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
websiteUrl,
holderName,
holderAddress,
originCountry: originCountry.trim(),
});
}
@@ -90,6 +99,26 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
onChange={(event) => setWebsiteUrl(event.target.value)}
/>
</div>
<Field
label="Herkunftsland"
required
hint="Erscheint auf der Herkunftsseite, z. B. „Deutschland“."
error={originCountryError}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
maxLength={80}
aria-describedby={describedBy}
aria-invalid={invalid}
value={originCountry}
onChange={(event) => {
setOriginCountry(event.target.value);
setOriginCountryError(null);
}}
/>
)}
</Field>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Bienenhalter (für das Bestandsbuch)</legend>
<p className="text-xs text-muted-foreground">
@@ -2,6 +2,7 @@
import { Tag } from "lucide-react";
import { createLabelTemplate } from "@/lib/label-templates/api";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer";
@@ -12,6 +13,11 @@ export default function NewLabelTemplatePage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/settings/automation"
label="Zur Automatisierung"
dirty={false}
/>
<PageHeader
icon={Tag}
title="Neue Etikettenvorlage"
+4 -158
View File
@@ -2,151 +2,14 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BarChart3,
CalendarDays,
Crown,
Database,
Droplet,
Factory,
Boxes,
FileClock,
FlaskConical,
Hexagon,
History,
LayoutDashboard,
Layers,
ListTodo,
type LucideIcon,
MapPin,
Package,
Scissors,
Search,
Snowflake,
ShoppingBag,
Users,
Warehouse,
Wrench,
Zap,
} from "lucide-react";
import { FileClock, Hexagon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AuthUser } from "@/lib/auth/auth-context";
interface NavItem {
label: string;
href: string;
icon: LucideIcon;
// Any one of these grants visibility - used for hub pages that fold
// together several previously-separate settings pages, each gated by its
// own permission.
permission?: string | string[];
}
interface NavGroup {
label: string | null;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
label: null,
items: [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Suche", href: "/search", icon: Search },
],
},
{
label: "Betrieb",
items: [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
{
label: "Überwinterung",
href: "/winter",
icon: Snowflake,
permission: "winter-checks:read",
},
],
},
{
label: "Arbeit",
items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
],
},
{
label: "Ernte & Lager",
items: [
{ label: "Ernten", href: "/harvests", icon: Scissors },
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package },
{
label: "Rohstoffe",
href: "/raw-materials",
icon: Boxes,
permission: "raw-material:read",
},
{
label: "Herstellungschargen",
href: "/production-batches",
icon: Factory,
permission: "production-batch:read",
},
{ label: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench },
],
},
{
label: "Auswertung",
items: [{ label: "Statistik", href: "/statistics", icon: BarChart3 }],
},
];
const ADMIN_GROUP: NavGroup = {
label: "Verwaltung",
items: [
{
label: "Benutzer & Rollen",
href: "/settings/users-roles",
icon: Users,
permission: ["users:read", "roles:read"],
},
{
label: "Automatisierung",
href: "/settings/automation",
icon: Zap,
permission: [
"webhooks:read",
"automation:read",
"task-templates:read",
"label-templates:read",
"site-settings:read",
"calendar:configure",
"api-keys:manage",
],
},
{
label: "Backup",
href: "/settings/backup",
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"],
},
{
label: "Audit-Log",
href: "/settings/audit-log",
icon: History,
permission: "audit:read",
},
],
};
import { isNavActive, type NavItem, visibleNavGroups } from "./nav-config";
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
const isActive = isNavActive(pathname, item.href);
return (
<Link
href={item.href}
@@ -169,26 +32,9 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
);
}
function hasNavPermission(
user: AuthUser,
permission: NavItem["permission"],
): boolean {
if (!permission) {
return true;
}
const required = Array.isArray(permission) ? permission : [permission];
return required.some((key) => user.permissions.includes(key));
}
export function Sidebar({ user }: { user: AuthUser }) {
const pathname = usePathname();
const visibleAdminItems = ADMIN_GROUP.items.filter((item) =>
hasNavPermission(user, item.permission),
);
const groups =
visibleAdminItems.length > 0
? [...NAV_GROUPS, { ...ADMIN_GROUP, items: visibleAdminItems }]
: NAV_GROUPS;
const groups = visibleNavGroups(user);
return (
// The page scrolls on the window; the sidebar stays put beside it and
@@ -2,7 +2,7 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Sprout } from "lucide-react";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
@@ -10,14 +10,14 @@ import {
SWARM_ACTION_LABELS,
SWARM_MOOD_LABELS,
} from "@/lib/swarm-control/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueSwarmControlCheck } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -50,6 +50,10 @@ function validate(values: SwarmControlFormValues): FieldErrors {
export default function NewSwarmControlCheckPage() {
const router = useRouter();
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const queryClient = useQueryClient();
const [values, setValues] = useState<SwarmControlFormValues>(() =>
@@ -57,7 +61,6 @@ export default function NewSwarmControlCheckPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("swarm-control-new", values, dirty);
function set<K extends keyof SwarmControlFormValues>(
@@ -71,43 +74,30 @@ export default function NewSwarmControlCheckPage() {
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createSwarmControlCheck>[0]) =>
createSwarmControlCheck(input, { idempotencyKey }),
onSuccess: (check) => {
const capture = useCaptureMutation({
create: createSwarmControlCheck,
queue: queueSwarmControlCheck,
failureMessage: "Schwarmkontrolle konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${input.colonyId}`);
return;
}
const check = outcome.result;
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
});
toast.success("Schwarmkontrolle gespeichert");
router.push(`/colonies/${check.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Schwarmkontrolle konnte nicht gespeichert werden.",
),
);
return;
}
void queueSwarmControlCheck(variables, idempotencyKey).then(() => {
draft.clear();
toast.info(
"Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -117,7 +107,7 @@ export default function NewSwarmControlCheckPage() {
?.focus();
return;
}
mutation.mutate(toSwarmControlInput(values));
capture.submit(toSwarmControlInput(values));
}
function restoreDraft() {
@@ -130,6 +120,12 @@ export default function NewSwarmControlCheckPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Sprout}
title="Neue Schwarmkontrolle"
@@ -270,12 +266,12 @@ export default function NewSwarmControlCheckPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -283,9 +279,9 @@ export default function NewSwarmControlCheckPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending
{capture.isPending
? "Wird gespeichert…"
: "Kontrolle speichern"}
</Button>
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { RecordSelect } from "@/components/form/record-select";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -108,6 +109,12 @@ export default function NewTaskPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/tasks"
label="Zu den Aufgaben"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={ListTodo}
title="Neue Aufgabe"
@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, onTestFinished, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
import NewTreatmentPage from "./page";
import { createTreatment } from "@/lib/treatments/api";
import { createTreatment, fetchTreatmentPrefill } from "@/lib/treatments/api";
import { fetchColonies } from "@/lib/colonies/api";
const push = vi.fn();
@@ -12,9 +12,22 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({
createTreatment: vi.fn(),
fetchTreatmentPrefill: vi.fn(),
}));
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn() }));
/** jsdom reports online; a test can take the device offline until it ends. */
function goOffline() {
Object.defineProperty(navigator, "onLine", { configurable: true, get: () => false });
onTestFinished(() => {
Reflect.deleteProperty(navigator, "onLine");
// TanStack's onlineManager is global and has seen the "offline" event.
onlineManager.setOnline(true);
});
}
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -34,8 +47,11 @@ describe("NewTreatmentPage", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createTreatment).mockReset();
vi.mocked(fetchTreatmentPrefill).mockReset().mockResolvedValue(null);
});
afterEach(() => vi.restoreAllMocks());
it("marks colony, date and product as required and keeps the Bestandsbuch visible", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(3);
@@ -91,4 +107,194 @@ describe("NewTreatmentPage", () => {
expect(screen.getByLabelText(/^Mittel/)).toHaveFocus();
expect(createTreatment).not.toHaveBeenCalled();
});
describe("prefill from the last treatment", () => {
const LAST = {
sourceTreatmentId: "t-0",
sourceDate: "2026-08-12T00:00:00.000Z",
product: "Oxuvar",
activeIngredient: "Oxalsäure",
withdrawalPeriodDays: 0,
supplier: "Imkereibedarf Muster",
purchaseProof: "Rechnung 4711",
prescribingVet: "Dr. Biene",
};
const WAIT = { timeout: 3000 };
it("after „Oxuvar“ fills Wirkstoff, Wartezeit, Lieferant, Beleg and Tierarzt with the hint", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Oxalsäure"),
WAIT,
);
expect(fetchTreatmentPrefill).toHaveBeenCalledWith("Oxuvar");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(0);
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster");
expect(screen.getByLabelText(/Beleg/)).toHaveValue("Rechnung 4711");
expect(screen.getByLabelText(/Tierarzt/)).toHaveValue("Dr. Biene");
expect(screen.getAllByText("aus letzter Behandlung mit Oxuvar vom 12.08.2026")).toHaveLength(5);
expect(screen.getByLabelText(/Chargennummer/)).toHaveValue("");
});
it("never overwrites a typed Wirkstoff", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "Ameisensäure");
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Ameisensäure");
expect(screen.getAllByText("aus letzter Behandlung mit Oxuvar vom 12.08.2026")).toHaveLength(4);
});
it("prefilled values stay editable and a cleared one is sent as null", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
await userEvent.clear(screen.getByLabelText(/Lieferant/));
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "x");
expect(screen.getAllByText("aus letzter Behandlung mit Oxuvar vom 12.08.2026")).toHaveLength(3);
await userEvent.click(screen.getByRole("button", { name: "Behandlung speichern" }));
const [input] = vi.mocked(createTreatment).mock.calls[0];
expect(input).toMatchObject({
supplier: null,
activeIngredient: "Oxalsäurex",
withdrawalPeriodDays: 0,
purchaseProof: "Rechnung 4711",
batchNumber: null,
});
});
it("takes back only untouched prefilled fields", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "!");
await userEvent.click(screen.getByRole("button", { name: "Vorbelegung zurücknehmen" }));
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("");
expect(screen.getByLabelText(/Tierarzt/)).toHaveValue("");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Oxalsäure!");
expect(screen.queryByText(/aus letzter Behandlung/)).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Vorbelegung zurücknehmen" }),
).not.toBeInTheDocument();
});
it("sends no prefill request while offline", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
goOffline();
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await act(() => vi.advanceTimersByTimeAsync(800));
expect(fetchTreatmentPrefill).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("drops the suggestions at once when the product changes", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(null);
await userEvent.type(screen.getByLabelText(/^Mittel/), " 60%");
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("");
expect(screen.queryByText(/aus letzter Behandlung/)).not.toBeInTheDocument();
});
it("sends nothing of product A when product B's request fails", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
vi.mocked(fetchTreatmentPrefill).mockRejectedValue(new Error("down"));
await userEvent.clear(screen.getByLabelText(/^Mittel/));
await userEvent.type(screen.getByLabelText(/^Mittel/), "Thymovar");
await waitFor(() => expect(fetchTreatmentPrefill).toHaveBeenCalledWith("Thymovar"), WAIT);
await userEvent.click(screen.getByRole("button", { name: "Behandlung speichern" }));
expect(vi.mocked(createTreatment).mock.calls[0][0]).toMatchObject({
product: "Thymovar",
activeIngredient: null,
withdrawalPeriodDays: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
});
});
it("keeps the suggestions of the same product when the connection drops", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
goOffline();
act(() => {
window.dispatchEvent(new Event("offline"));
});
await new Promise((resolve) => setTimeout(resolve, 700));
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster");
expect(screen.getAllByText("aus letzter Behandlung mit Oxuvar vom 12.08.2026")).toHaveLength(5);
});
it("does not refill a field the user cleared when the product changes", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
await userEvent.clear(screen.getByLabelText(/Lieferant/));
vi.mocked(fetchTreatmentPrefill).mockResolvedValue({ ...LAST, supplier: "Anderer", activeIngredient: "Thymol" });
await userEvent.type(screen.getByLabelText(/^Mittel/), "2");
await waitFor(() => expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Thymol"), WAIT);
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("");
await userEvent.click(screen.getByRole("button", { name: "Behandlung speichern" }));
expect(vi.mocked(createTreatment).mock.calls[0][0]).toMatchObject({ supplier: null });
});
});
});
@@ -2,18 +2,20 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Syringe } from "lucide-react";
import { createTreatment } from "@/lib/treatments/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueTreatment } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { usePrefilledFields } from "@/lib/treatments/use-prefilled-fields";
import { PrefillNote, describedByWith } from "@/components/treatments/prefill-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -43,6 +45,10 @@ function validate(values: TreatmentFormValues): FieldErrors {
export default function NewTreatmentPage() {
const router = useRouter();
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const queryClient = useQueryClient();
@@ -51,57 +57,47 @@ export default function NewTreatmentPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("treatment-new", values, dirty);
const { prefilled, markEdited, undo: undoPrefill, reset: resetPrefill } =
usePrefilledFields(values, setValues);
function set<K extends keyof TreatmentFormValues>(
key: K,
value: TreatmentFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
markEdited(key);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createTreatment>[0]) =>
createTreatment(input, { idempotencyKey }),
onSuccess: (treatment) => {
const capture = useCaptureMutation({
create: createTreatment,
queue: queueTreatment,
failureMessage: "Behandlung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${input.colonyId}`);
return;
}
const treatment = outcome.result;
void queryClient.invalidateQueries({
queryKey: ["colonies", treatment.colonyId, "treatments"],
});
toast.success("Behandlung gespeichert");
router.push(`/colonies/${treatment.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Behandlung konnte nicht gespeichert werden.",
),
);
return;
}
void queueTreatment(variables, idempotencyKey).then(() => {
draft.clear();
toast.info(
"Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -111,19 +107,26 @@ export default function NewTreatmentPage() {
?.focus();
return;
}
mutation.mutate(toTreatmentInput(values));
capture.submit(toTreatmentInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
resetPrefill();
setDirty(true);
}
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Syringe}
title="Neue Behandlung"
@@ -193,15 +196,18 @@ export default function NewTreatmentPage() {
hint="Steht auf der Packung, z. B. Ameisensäure."
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.activeIngredient}
onChange={(event) =>
set("activeIngredient", event.target.value)
}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, describedBy, prefilled.activeIngredient)}
className="h-11"
value={values.activeIngredient}
onChange={(event) =>
set("activeIngredient", event.target.value)
}
/>
<PrefillNote id={id} source={prefilled.activeIngredient} />
</>
)}
</Field>
<Field label="Menge" hint="z. B. „50 ml“">
@@ -245,18 +251,21 @@ export default function NewTreatmentPage() {
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.withdrawalPeriodDays}
onChange={(event) =>
set("withdrawalPeriodDays", event.target.value)
}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, describedBy, prefilled.withdrawalPeriodDays)}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.withdrawalPeriodDays}
onChange={(event) =>
set("withdrawalPeriodDays", event.target.value)
}
/>
<PrefillNote id={id} source={prefilled.withdrawalPeriodDays} />
</>
)}
</Field>
</FormSection>
@@ -268,40 +277,58 @@ export default function NewTreatmentPage() {
>
<Field label="Lieferant (Name, Anschrift)">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={values.supplier}
onChange={(event) => set("supplier", event.target.value)}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.supplier)}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={values.supplier}
onChange={(event) => set("supplier", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.supplier} />
</>
)}
</Field>
<Field label="Beleg für den Erwerb">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={values.purchaseProof}
onChange={(event) =>
set("purchaseProof", event.target.value)
}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.purchaseProof)}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={values.purchaseProof}
onChange={(event) => set("purchaseProof", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.purchaseProof} />
</>
)}
</Field>
<Field label="Verschreibender Tierarzt (falls zutreffend)">
{({ id }) => (
<Input
id={id}
className="h-11"
value={values.prescribingVet}
onChange={(event) =>
set("prescribingVet", event.target.value)
}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.prescribingVet)}
className="h-11"
value={values.prescribingVet}
onChange={(event) => set("prescribingVet", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.prescribingVet} />
</>
)}
</Field>
{Object.keys(prefilled).length > 0 ? (
<Button
type="button"
variant="outline"
className="h-11 self-start"
onClick={undoPrefill}
>
Vorbelegung zurücknehmen
</Button>
) : null}
</FormSection>
<FormSection
@@ -360,12 +387,12 @@ export default function NewTreatmentPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -373,9 +400,9 @@ export default function NewTreatmentPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending
{capture.isPending
? "Wird gespeichert…"
: "Behandlung speichern"}
</Button>
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewVarroaMeasurementPage from "./page";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createVarroaMeasurement, fetchVarroaThreshold } from "@/lib/varroa/api";
import { fetchColonies } from "@/lib/colonies/api";
const push = vi.fn();
@@ -12,7 +12,11 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/varroa/api", () => ({ createVarroaMeasurement: vi.fn() }));
vi.mock("@/lib/varroa/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/varroa/api")>()),
createVarroaMeasurement: vi.fn(),
fetchVarroaThreshold: vi.fn(),
}));
vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn() }));
function renderPage() {
@@ -34,6 +38,7 @@ describe("NewVarroaMeasurementPage", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createVarroaMeasurement).mockReset();
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
});
it("marks colony and date as required and explains the daily mite fall", () => {
@@ -57,7 +62,7 @@ describe("NewVarroaMeasurementPage", () => {
screen.getByRole("radio", { name: "Puderzuckermethode" }),
);
await userEvent.type(screen.getByLabelText(/Milbenzahl/), "12");
await userEvent.type(screen.getByLabelText(/Zeitraum/), "5");
await userEvent.type(screen.getByLabelText(/^Zeitraum/), "5");
await userEvent.click(
screen.getByRole("button", { name: "Messung speichern" }),
);
@@ -74,6 +79,70 @@ describe("NewVarroaMeasurementPage", () => {
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
async function fillMiteCount(count: string) {
await userEvent.type(screen.getByLabelText(/Milbenzahl/), count);
}
async function submit() {
await userEvent.click(screen.getByRole("button", { name: "Messung speichern" }));
}
it("preselects no period chip", () => {
renderPage();
for (const name of ["1 Tag", "2 Tage", "3 Tage", "7 Tage", "anders"]) {
expect(screen.getByRole("radio", { name })).toHaveAttribute("aria-checked", "false");
}
});
it('„3 Tage" sends durationMinutes 4320', async () => {
vi.mocked(createVarroaMeasurement).mockResolvedValue({ id: "v", colonyId: "colony-1" } as never);
renderPage();
await fillMiteCount("30");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
await submit();
expect(vi.mocked(createVarroaMeasurement).mock.calls[0][0]).toMatchObject({
durationMinutes: 4320,
miteCount: 30,
});
});
it('„anders" 5 days sends 7200', async () => {
vi.mocked(createVarroaMeasurement).mockResolvedValue({ id: "v", colonyId: "colony-1" } as never);
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "anders" }));
await userEvent.type(screen.getByLabelText(/^Tage/), "5");
await submit();
expect(vi.mocked(createVarroaMeasurement).mock.calls[0][0]).toMatchObject({
durationMinutes: 7200,
});
});
it('shows „10,0 Milben/Tag – über der Schwelle" live', async () => {
renderPage();
await fillMiteCount("30");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
expect(
await screen.findByText(/10,0 Milben\/Tag – über der Schwelle, Behandlung prüfen/),
).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("über der Schwelle");
});
it("rate without traffic light when the rule is inactive", async () => {
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: null });
renderPage();
await fillMiteCount("30");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
const status = await screen.findByText(/10,0 Milben\/Tag/);
expect(status).not.toHaveTextContent("Schwelle");
});
it("other methods keep the minutes field", async () => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Waschmethode" }));
expect(screen.getByLabelText(/Zeitraum \(Minuten\)/)).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "3 Tage" })).not.toBeInTheDocument();
});
it("shows an inline error and focuses the date when it is missing", async () => {
renderPage();
await userEvent.clear(screen.getByLabelText(/^Datum/));
@@ -85,4 +154,18 @@ describe("NewVarroaMeasurementPage", () => {
expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
expect(createVarroaMeasurement).not.toHaveBeenCalled();
});
it("seeds the Tage field from the chosen chip when switching to anders", async () => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
await userEvent.click(screen.getByRole("radio", { name: "anders" }));
expect(screen.getByLabelText(/^Tage/)).toHaveValue("3");
});
it("explains an invalid number of days instead of silently clearing", async () => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "anders" }));
await userEvent.type(screen.getByLabelText(/^Tage/), "0");
expect(screen.getByRole("alert")).toHaveTextContent("größer als 0");
});
});
@@ -2,19 +2,24 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Bug } from "lucide-react";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { mitesPerDay } from "@/lib/varroa/rate";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
import { VarroaRateLight } from "@/components/varroa/varroa-rate-light";
import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueVarroaMeasurement } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -46,6 +51,10 @@ function validate(values: VarroaFormValues): FieldErrors {
export default function NewVarroaMeasurementPage() {
const router = useRouter();
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const queryClient = useQueryClient();
const [values, setValues] = useState<VarroaFormValues>(() =>
@@ -53,7 +62,6 @@ export default function NewVarroaMeasurementPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("varroa-new", values, dirty);
function set<K extends keyof VarroaFormValues>(
@@ -67,43 +75,30 @@ export default function NewVarroaMeasurementPage() {
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createVarroaMeasurement>[0]) =>
createVarroaMeasurement(input, { idempotencyKey }),
onSuccess: (measurement) => {
const capture = useCaptureMutation({
create: createVarroaMeasurement,
queue: queueVarroaMeasurement,
failureMessage: "Varroamessung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${input.colonyId}`);
return;
}
const measurement = outcome.result;
void queryClient.invalidateQueries({
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
});
toast.success("Varroamessung gespeichert");
router.push(`/colonies/${measurement.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Varroamessung konnte nicht gespeichert werden.",
),
);
return;
}
void queueVarroaMeasurement(variables, idempotencyKey).then(() => {
draft.clear();
toast.info(
"Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -113,7 +108,7 @@ export default function NewVarroaMeasurementPage() {
?.focus();
return;
}
mutation.mutate(toVarroaInput(values));
capture.submit(toVarroaInput(values));
}
function restoreDraft() {
@@ -125,9 +120,22 @@ export default function NewVarroaMeasurementPage() {
}
const isNaturalFall = values.method === "NATURAL_MITE_FALL";
const threshold = useVarroaThreshold();
const rate = isNaturalFall
? mitesPerDay(
emptyToNull(values.miteCount, Number),
emptyToNull(values.durationMinutes, Number),
)
: null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Bug}
title="Neue Varroamessung"
@@ -210,29 +218,39 @@ export default function NewVarroaMeasurementPage() {
/>
)}
</Field>
<Field
label="Zeitraum (Minuten)"
hint={
isNaturalFall
? "Wie lange die Windel lag: 1 Tag = 1440 Minuten, 3 Tage = 4320 Minuten."
: "Wie lange die Messung gedauert hat."
}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.durationMinutes}
onChange={(event) =>
set("durationMinutes", event.target.value)
}
{isNaturalFall ? (
<>
<VarroaPeriodField
minutes={values.durationMinutes}
onChange={(minutes) => set("durationMinutes", minutes)}
hint="Wie lange die Windel lag."
/>
)}
</Field>
<VarroaRateLight
rate={rate}
threshold={threshold}
/>
</>
) : (
<Field
label="Zeitraum (Minuten)"
hint="Wie lange die Messung gedauert hat."
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.durationMinutes}
onChange={(event) =>
set("durationMinutes", event.target.value)
}
/>
)}
</Field>
)}
</FormSection>
<FormSection
@@ -266,12 +284,12 @@ export default function NewVarroaMeasurementPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -279,9 +297,9 @@ export default function NewVarroaMeasurementPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending ? "Wird gespeichert…" : "Messung speichern"}
{capture.isPending ? "Wird gespeichert…" : "Messung speichern"}
</Button>
</StickySubmit>
</form>
@@ -17,7 +17,7 @@ export interface VarroaFormValues {
/** One plain sentence per method: what is counted and how to read it. */
export const VARROA_METHOD_HINTS: Record<VarroaMethod, string> = {
NATURAL_MITE_FALL:
"Milben auf der Windel unter dem Gitterboden zählen. Gezählte Milben durch die Anzahl Tage teilen – nur dieser Milbenfall pro Tag lässt sich mit der Schadschwelle vergleichen.",
"Milben auf der Windel unter dem Gitterboden zählen. Zeitraum wählen – die App rechnet auf Milben pro Tag um. Nur dieser Milbenfall pro Tag lässt sich mit der Schadschwelle vergleichen.",
POWDERED_SUGAR:
"Bienenprobe mit Puderzucker bestäuben und schütteln, herausgefallene Milben zählen.",
WASHING_METHOD:
@@ -67,6 +67,31 @@ describe("LoginPage", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(/E-Mail oder Passwort falsch/i);
});
it("shows a connection message instead of a wrong-password hint on a timeout", async () => {
loginMock.mockRejectedValueOnce(new DOMException("t", "TimeoutError"));
render(<LoginPage />);
await userEvent.type(screen.getByLabelText("E-Mail"), "[email protected]");
await userEvent.type(screen.getByLabelText("Passwort"), "ChangeMe123!");
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Keine Verbindung zum Server/i);
});
it("shows a connection message when the MFA verification times out", async () => {
loginMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "mfa-token" });
verifyMfaMock.mockRejectedValueOnce(new DOMException("t", "TimeoutError"));
render(<LoginPage />);
await userEvent.type(screen.getByLabelText("E-Mail"), "[email protected]");
await userEvent.type(screen.getByLabelText("Passwort"), "ChangeMe123!");
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
await userEvent.type(await screen.findByLabelText("Code"), "000000");
await userEvent.click(screen.getByRole("button", { name: /bestätigen/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Keine Verbindung zum Server/i);
});
it("shows the code step and verifies MFA when required", async () => {
loginMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "mfa-token" });
verifyMfaMock.mockResolvedValueOnce(undefined);
+12 -4
View File
@@ -1,5 +1,6 @@
"use client";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { FormEvent, Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Hexagon } from "lucide-react";
@@ -25,6 +26,9 @@ const SSO_ERROR_MESSAGES: Record<string, string> = {
"Kein ApiCore-Konto mit dieser E-Mail-Adresse gefunden. Bitte an eine Administratorin oder einen Administrator wenden.",
};
const NETWORK_ERROR_MESSAGE =
"Keine Verbindung zum Server. Bitte Verbindung prüfen und erneut versuchen.";
export default function LoginPage() {
return (
<Suspense fallback={null}>
@@ -106,9 +110,11 @@ function LoginPageContent() {
} else {
router.push("/dashboard");
}
} catch {
} catch (caught) {
setError(
"E-Mail oder Passwort falsch. Nach 5 Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.",
isNetworkError(caught)
? NETWORK_ERROR_MESSAGE
: "E-Mail oder Passwort falsch. Nach 5 Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.",
);
} finally {
setIsSubmitting(false);
@@ -125,9 +131,11 @@ function LoginPageContent() {
try {
await verifyMfa(mfaToken, code);
router.push("/dashboard");
} catch {
} catch (caught) {
setError(
"Code ungültig oder schon benutzt. Bitte den nächsten Code aus der App eingeben.",
isNetworkError(caught)
? NETWORK_ERROR_MESSAGE
: "Code ungültig oder schon benutzt. Bitte den nächsten Code aus der App eingeben.",
);
} finally {
setIsSubmitting(false);
+3 -3
View File
@@ -98,15 +98,15 @@
--success: oklch(0.46 0.06 145);
--success-foreground: oklch(0.98 0.015 100);
--condition-excellent: oklch(0.58 0.13 145);
--condition-excellent-foreground: oklch(0.24 0.05 145);
--condition-excellent-foreground: oklch(0.16 0.04 145);
--condition-good: oklch(0.64 0.13 118);
--condition-good-foreground: oklch(0.26 0.05 118);
--condition-average: oklch(0.7 0.14 90);
--condition-average-foreground: oklch(0.28 0.05 90);
--condition-weak: oklch(0.68 0.15 55);
--condition-weak-foreground: oklch(0.26 0.05 55);
--condition-critical: oklch(0.55 0.16 30);
--condition-critical-foreground: oklch(0.24 0.05 30);
--condition-critical: oklch(0.64 0.16 30);
--condition-critical-foreground: oklch(0.15 0.04 30);
--chart-1: oklch(0.6 0.135 50);
--chart-2: oklch(0.46 0.06 145);
--chart-3: oklch(0.68 0.14 75);
@@ -16,6 +16,7 @@ import {
type LucideIcon,
} from "lucide-react";
import { fetchPublicBatch } from "@/lib/public-batch/api";
import { originText } from "@/lib/public-batch/origin-text";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
@@ -99,14 +100,6 @@ export default function PublicBatchPage() {
const harvestMonths = batch
? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
: null;
// Standort statt Bienenstand-Name: mehrere Bienenstände am selben Ort
// sollen als ein Herkunftsort erscheinen, nicht als mehrere interne Namen.
// Fällt auf den Namen zurück, falls für einen Bienenstand kein Standort
// hinterlegt ist, damit die Zeile nie leer bleibt.
const originLabels = batch
? [...new Set(batch.apiaries.map((a) => a.location?.trim() || a.name))]
: [];
const originText = originLabels.join(", ");
const socialLinks = batch
? [
{ label: "YouTube", url: batch.socialLinks.youtube },
@@ -163,9 +156,7 @@ export default function PublicBatchPage() {
) : null}
<dl className="flex flex-col gap-3 text-sm">
{originText ? (
<InfoRow icon={MapPin} label="Herkunft" value={originText} />
) : null}
<InfoRow icon={MapPin} label="Herkunft" value={originText(batch.origin)} />
{harvestMonths ? (
<InfoRow icon={CalendarDays} label="Geerntet" value={harvestMonths} />
) : null}
@@ -0,0 +1,40 @@
import { useMutation } from "@tanstack/react-query";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Providers } from "./providers";
vi.mock("@/lib/auth/auth-context", () => ({
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
function Probe({ mutationFn }: { mutationFn: () => Promise<string> }) {
const mutation = useMutation({ mutationFn });
return <button onClick={() => mutation.mutate()}>Speichern</button>;
}
describe("Providers", () => {
afterEach(() => {
vi.restoreAllMocks();
// TanStack's onlineManager tracks the browser events, so restore it too.
act(() => {
window.dispatchEvent(new Event("online"));
});
});
it("runs mutations while navigator.onLine is false", async () => {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false);
const mutationFn = vi.fn().mockResolvedValue("ok");
render(
<Providers>
<Probe mutationFn={mutationFn} />
</Providers>,
);
// onlineManager (listening once mounted) only learns about airplane mode through the window event.
act(() => {
window.dispatchEvent(new Event("offline"));
});
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(mutationFn).toHaveBeenCalledTimes(1));
});
});
+16 -1
View File
@@ -2,11 +2,26 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { ApiError } from "@/lib/auth/api-client";
import { AuthProvider } from "@/lib/auth/auth-context";
import { applyMotionLevel, readMotionLevel } from "@/lib/motion/motion-preference";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
// offlineFirst: serve cache / try the request even without a network
// signal; 'always' mutations run so forms can queue instead of hanging.
queries: {
networkMode: "offlineFirst",
retry: (failureCount, error) =>
!(error instanceof ApiError && error.status < 500) && failureCount < 1,
},
mutations: { networkMode: "always", retry: false },
},
}),
);
useEffect(() => {
applyMotionLevel(readMotionLevel());