Merge main checkpoint c01a388 into feat/erp-inventar-export

Brings the ERP inventory export up to date with the platform backbone and
field capture work. Conflicts in ARCHITECTURE.md, the API key service and
the API key settings tab resolved by keeping both sides:
- read and write keys stay separate; erp-inventory:read is read-only and
  cannot be combined with a write scope
- the failed-attempt lockout counts in the shared ThrottlerHit table so it
  holds across API pods
- the UI offers only scopes the user holds and creates one scope per key

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 22:49:53 +02:00
commit 53fab102f2
347 files changed
+26087 -2141

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">
@@ -2,10 +2,9 @@ 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 type { ReactElement } from "react";
import { ApiKeysTab } from "./api-keys-tab";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import type { ApiKey } from "@/lib/api-keys/types";
import type { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
// Rechte des angemeldeten Nutzers; je Test anpassbar.
let ownPermissions: string[] = [];
@@ -13,24 +12,22 @@ vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => ownPermissions.includes(permission) }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/api-keys/api", () => ({
createApiKey: vi.fn(),
fetchApiKeys: vi.fn(),
createApiKey: vi.fn(),
revokeApiKey: vi.fn(),
updateApiKeyAllowedIps: vi.fn(),
}));
function renderWithClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
const HINT = /Dieser Schlüssel darf lesen und buchen/;
function key(overrides: Partial<ApiKey>): ApiKey {
function makeKey(id: string, scopes: ApiKeyScope[], overrides: Partial<ApiKey> = {}): ApiKey {
return {
id: "k1",
name: "Schlüssel",
prefix: "ack_abcd1234",
scopes: ["shop-sync:read"],
id,
name: `Schlüssel ${id}`,
prefix: `ack_${id}`,
scopes,
createdByUserId: null,
createdAt: "2026-10-01T00:00:00.000Z",
lastUsedAt: null,
@@ -42,79 +39,165 @@ function key(overrides: Partial<ApiKey>): ApiKey {
};
}
function renderTab() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ApiKeysTab />
</QueryClientProvider>,
);
}
describe("ApiKeysTab", () => {
beforeEach(() => {
vi.clearAllMocks();
ownPermissions = ["api-keys:manage", "shop-sync:read", "shop-sync:write", "erp-inventory:read"];
vi.mocked(fetchApiKeys).mockReset().mockResolvedValue([]);
vi.mocked(createApiKey).mockReset();
vi.mocked(revokeApiKey).mockReset();
});
it("legt einen Schlüssel nur mit den angekreuzten Berechtigungen an", async () => {
vi.mocked(createApiKey).mockResolvedValueOnce({
apiKey: key({ name: "ERPNext", scopes: ["erp-inventory:read"] }),
// Synthetischer Wert, kein echter Schlüssel.
token: `ack_abcd1234_${"A".repeat(43)}`,
});
renderWithClient(<ApiKeysTab />);
describe("separation hint", () => {
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 name = screen.getByLabelText("Wofür ist der Schlüssel?");
await userEvent.clear(name);
await userEvent.type(name, "ERPNext");
await userEvent.click(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ }));
await userEvent.click(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0]).toMatchObject({
name: "ERPNext",
scopes: ["erp-inventory:read"],
const hints = await screen.findAllByRole("status");
expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent(HINT);
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
});
it("also marks a key that reads the ERP mirror and books", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("mixed", ["erp-inventory:read", "shop-sync:write"]),
]);
renderTab();
expect(await screen.findByRole("status")).toHaveTextContent(HINT);
});
it("shows no hint when every key has one scope", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("read", ["shop-sync:read"]),
makeKey("erp", ["erp-inventory:read"]),
]);
renderTab();
await screen.findByText("Schlüssel read");
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
});
expect(await screen.findByText(/in ERPNext als Secret/)).toBeInTheDocument();
});
it("schlägt für den Shop wie bisher nur das Lesen vor", () => {
renderWithClient(<ApiKeysTab />);
expect(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ })).toHaveAttribute(
"aria-checked",
"true",
);
expect(
screen.getByRole("checkbox", { name: /Verkäufe und Rückbuchungen/ }),
).toHaveAttribute("aria-checked", "false");
expect(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ })).toHaveAttribute(
"aria-checked",
"false",
);
describe("creating", () => {
it("creates a read key by default", async () => {
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 "Shop buchen" is chosen', async () => {
vi.mocked(createApiKey).mockResolvedValue({
apiKey: makeKey("new", ["shop-sync:write"]),
token: "t",
});
renderTab();
await userEvent.click(await screen.findByRole("radio", { name: "Shop buchen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
});
it("legt einen ERP-Schlüssel nur mit dem Inventar-Recht an und sagt, wohin er gehört", async () => {
vi.mocked(createApiKey).mockResolvedValueOnce({
apiKey: makeKey("erp", ["erp-inventory:read"], { name: "ERPNext" }),
// Synthetischer Wert, kein echter Schlüssel.
token: `ack_abcd1234_${"A".repeat(43)}`,
});
renderTab();
const name = screen.getByLabelText("Wofür ist der Schlüssel?");
await userEvent.clear(name);
await userEvent.type(name, "ERPNext");
await userEvent.click(screen.getByRole("radio", { name: "ERP-Inventar lesen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0]).toMatchObject({
name: "ERPNext",
scopes: ["erp-inventory:read"],
});
expect(await screen.findByText(/in ERPNext als Secret/)).toBeInTheDocument();
});
it("schlägt für den Shop wie bisher das Lesen vor, genau ein Recht je Schlüssel", () => {
renderTab();
expect(screen.getByRole("radio", { name: "Shop lesen" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByRole("radio", { name: "Shop buchen" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
});
});
it("lässt ohne Berechtigung nichts anlegen", async () => {
renderWithClient(<ApiKeysTab />);
await userEvent.click(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ }));
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
describe("only grantable scopes", () => {
it("bietet nur Rechte an, die der angemeldete Nutzer selbst hat", () => {
ownPermissions = ["api-keys:manage", "shop-sync:read", "erp-inventory:read"];
renderTab();
expect(screen.getByRole("radio", { name: "Shop lesen" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Shop buchen" })).toBeNull();
});
it("wählt ein erlaubtes Recht vor, wenn der Nutzer das Shop-Lesen nicht hat", async () => {
ownPermissions = ["api-keys:manage", "erp-inventory:read"];
vi.mocked(createApiKey).mockResolvedValue({
apiKey: makeKey("erp", ["erp-inventory:read"]),
token: "t",
});
renderTab();
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toHaveAttribute(
"aria-checked",
"true",
);
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["erp-inventory:read"]);
});
it("wählt nichts vor, was der Nutzer nicht vergeben darf, und erklärt es", () => {
ownPermissions = ["api-keys:manage"];
renderTab();
expect(screen.queryAllByRole("radio")).toHaveLength(0);
expect(screen.getByText(/keine Rechte, die ein API-Schlüssel tragen kann/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
});
});
it("zeigt in der Liste, was ein Schlüssel darf", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
key({ id: "k2", name: "ERPNext", scopes: ["erp-inventory:read"] }),
makeKey("k2", ["erp-inventory:read"], { name: "ERPNext" }),
]);
renderWithClient(<ApiKeysTab />);
renderTab();
expect(await screen.findByText("ERPNext")).toBeInTheDocument();
expect(screen.getByText("Darf: Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument();
});
it("bietet nur Rechte an, die der angemeldete Nutzer selbst hat", () => {
ownPermissions = ["api-keys:manage", "shop-sync:read", "erp-inventory:read"];
renderWithClient(<ApiKeysTab />);
expect(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ })).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /Verkäufe und Rückbuchungen/ })).toBeNull();
});
it("wählt nichts vor, was der Nutzer nicht vergeben darf, und erklärt es", () => {
ownPermissions = ["api-keys:manage"];
renderWithClient(<ApiKeysTab />);
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
expect(screen.getByText(/keine Rechte, die ein API-Schlüssel tragen kann/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
expect(screen.getByText("Darf:")).toBeInTheDocument();
expect(screen.getByText("Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument();
});
});
@@ -5,24 +5,31 @@ 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 { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import {
API_KEY_SCOPE_LABELS,
API_KEY_SCOPES,
ApiKey,
ApiKeyScope,
apiKeyScopeLabel,
} from "@/lib/api-keys/types";
hasReadAndWriteScopes,
} 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";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { ToggleChip } from "@/components/form/toggle-chip";
import { useAuth } from "@/lib/auth/auth-context";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
// Vorschlag wie bisher: der Wabenhain-Shop liest. Alles andere wird bewusst angekreuzt.
const DEFAULT_SCOPES: readonly ApiKeyScope[] = ["shop-sync:read"];
// Genau ein Recht je Schlüssel: Lesen und Buchen nie zusammen, und je
// angebundenem System ein eigener Schlüssel. Vorschlag wie bisher: der Shop liest.
const DEFAULT_SCOPE: ApiKeyScope = "shop-sync:read";
const SCOPE_CHOICE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop lesen",
"shop-sync:write": "Shop buchen",
"erp-inventory:read": "ERP-Inventar lesen",
};
const SCOPE_LABEL_ID = "api-key-scope-label";
/** Wohin der frisch angelegte Schlüssel gehört - je nach Berechtigung. */
function pasteHint(scopes: readonly ApiKeyScope[]) {
@@ -52,8 +59,9 @@ function keyState(apiKey: ApiKey): { label: string; active: boolean } {
}
/**
* Keys for machine access (the Wabenhain shop sync) instead of a service
* account with a password. The key is shown once; ApiCore keeps only a hash.
* Keys for machine access (the Wabenhain shop sync, ERPNext's inventory
* mirror) instead of a service account with a password. The key is shown
* once; ApiCore keeps only a hash.
*/
export function ApiKeysTab() {
const queryClient = useQueryClient();
@@ -64,8 +72,12 @@ export function ApiKeysTab() {
const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState("");
const [allowedIps, setAllowedIps] = useState("");
const [chosenScopes, setScopes] = useState<ApiKeyScope[]>([...DEFAULT_SCOPES]);
const scopes = chosenScopes.filter((scope) => grantable.includes(scope));
const [chosenScope, setScope] = useState<ApiKeyScope>(DEFAULT_SCOPE);
// Fehlt dem Nutzer das gewählte Recht, gilt das erste, das er vergeben darf.
const scope: ApiKeyScope | undefined = grantable.includes(chosenScope)
? chosenScope
: grantable[0];
const scopeOptions = choiceOptions(SCOPE_CHOICE_LABELS, grantable);
const [created, setCreated] = useState<{
name: string;
token: string;
@@ -108,21 +120,16 @@ export function ApiKeysTab() {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!scope) return;
const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({
name: name.trim(),
scopes,
scopes: [scope],
expiresInDays: days,
allowedIps: parseAllowlist(allowedIps),
});
}
function toggleScope(scope: ApiKeyScope, checked: boolean) {
setScopes((current) =>
API_KEY_SCOPES.filter((s) => (s === scope ? checked : current.includes(s))),
);
}
async function copyToken() {
if (!created) return;
try {
@@ -136,9 +143,9 @@ export function ApiKeysTab() {
return (
<div className="flex flex-col gap-6">
<p className="max-w-prose text-sm text-muted-foreground">
Ein API-Schlüssel erlaubt einem anderen System genau das, was angekreuzt ist – etwa dem
Wabenhain-Shop, die freigegebenen Shop-Daten zu lesen, oder dem Buchhaltungssystem, den
Inventar-Spiegel zu lesen. Er ist kein Benutzerkonto, hat kein Passwort und lässt sich
Ein API-Schlüssel erlaubt einem anderen System genau eine Sache – etwa dem Wabenhain-Shop,
die freigegebenen Shop-Daten zu lesen, oder dem Buchhaltungssystem, den Inventar-Spiegel zu
lesen. Er ist kein Benutzerkonto, hat kein Passwort und lässt sich
jederzeit widerrufen.
</p>
@@ -181,6 +188,22 @@ export function ApiKeysTab() {
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex w-full flex-col gap-1.5">
<Label id={SCOPE_LABEL_ID}>Berechtigung</Label>
{scope ? (
<ChoiceGroup
options={scopeOptions}
value={scope}
onChange={setScope}
labelledBy={SCOPE_LABEL_ID}
className="grid-cols-1 sm:grid-cols-3"
/>
) : (
<p className="text-sm text-muted-foreground">
Sie haben selbst keine Rechte, die ein API-Schlüssel tragen kann.
</p>
)}
</div>
<div className="flex w-44 flex-col gap-1.5">
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
<Input
@@ -206,37 +229,20 @@ export function ApiKeysTab() {
aria-describedby="api-key-ips-hint"
/>
</div>
<fieldset className="flex w-full flex-col gap-1.5">
<legend className="mb-1.5 text-sm font-medium">Was darf der Schlüssel?</legend>
{grantable.length === 0 ? (
<p className="text-sm text-muted-foreground">
Sie haben selbst keine Rechte, die ein API-Schlüssel tragen kann.
</p>
) : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{grantable.map((scope) => (
<ToggleChip
key={scope}
label={API_KEY_SCOPE_LABELS[scope]}
checked={scopes.includes(scope)}
onChange={(checked) => toggleScope(scope, checked)}
/>
))}
</div>
</fieldset>
<Button
type="submit"
size="touch"
disabled={createMutation.isPending || !name.trim() || scopes.length === 0}
disabled={createMutation.isPending || !name.trim() || !scope}
>
<KeyRound aria-hidden />
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
</Button>
</form>
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
Je angebundenem System ein eigener Schlüssel mit nur den nötigen Rechten. IP-Adressen mit
Komma trennen; leer = von überall. Unbekannt? Leer lassen, einmal abrufen lassen, dann hier
„Adressen ändern“ – die zuletzt benutzte Adresse lässt sich übernehmen.
{scope ? `Berechtigung: ${API_KEY_SCOPE_LABELS[scope]}. ` : null}Je angebundenem System ein
eigener Schlüssel; Lesen und Buchen bekommen je einen eigenen. IP-Adressen mit Komma trennen;
leer = von überall. Unbekannt? Leer lassen, einmal abrufen lassen, dann hier „Adressen
ändern“ – die zuletzt benutzte Adresse lässt sich übernehmen.
</p>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -264,9 +270,21 @@ 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="text-xs text-muted-foreground">
Darf: {apiKey.scopes.map(apiKeyScopeLabel).join(" · ")}
<span className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground">Darf:</span>
{apiKey.scopes.map((keyScope) => (
<Badge key={keyScope} variant="secondary">
{apiKeyScopeLabel(keyScope)}
</Badge>
))}
</span>
{state.active && hasReadAndWriteScopes(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());