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

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

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

Not rolled out. Remaining tasks follow in later commits.

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

No files matched your search

+3 -2
View File
@@ -1,6 +1,7 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
# Base-Image per Digest gepinnt, derselbe wie im Backend (siehe dort).
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
@@ -11,7 +12,7 @@ RUN npx next typegen
RUN npm run lint && npx tsc --noEmit && npm test
RUN npm run build
FROM node:22-alpine AS runtime
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/public ./public
+11
View File
@@ -37,6 +37,7 @@
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^30.0.1",
"tailwindcss": "^4",
"typescript": "^5",
@@ -6850,6 +6851,16 @@
"express": ">= 4.11"
}
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+1
View File
@@ -41,6 +41,7 @@
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^30.0.1",
"tailwindcss": "^4",
"typescript": "^5",
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
import { readRound } from "@/lib/rounds/round-state";
import type { ColonyOverview, ColonyOverviewRow } from "@/lib/apiaries/field-types";
vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/field-api")>()),
fetchColonyOverview: vi.fn(),
}));
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
const apiary = { id: "a-1", name: "Obstwiese" };
function row(overrides: Partial<ColonyOverviewRow>): ColonyOverviewRow {
return {
id: "c-1",
code: "A1",
status: "ACTIVE",
strength: 6,
broodStatus: "GOOD",
feedStatus: "GOOD",
healthStatus: "GOOD",
lastInspectionDate: new Date().toISOString(),
openWarnings: 0,
openCritical: 0,
...overrides,
};
}
function overview(rows: ColonyOverviewRow[]): ColonyOverview {
return { apiaryId: "a-1", inspectionsVisible: true, rows };
}
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<StartRoundButton apiary={apiary} />
<ApiaryColoniesCard apiary={apiary} />
</QueryClientProvider>,
);
}
describe("ApiaryColoniesCard", () => {
beforeEach(() => {
window.sessionStorage.clear();
vi.mocked(fetchColonyOverview).mockReset();
});
it("lists living colonies with light and reasons", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overview([
row({ id: "c-1", code: "A1" }),
row({ id: "c-2", code: "A2", status: "QUEENLESS", openWarnings: 2 }),
]),
);
renderCard();
expect(await screen.findByRole("heading", { name: "Völker (2)" })).toBeInTheDocument();
const first = screen.getByRole("link", { name: /A1/ });
expect(first).toHaveAttribute("href", "/colonies/c-1");
expect(within(first).getByText("Gut")).toBeInTheDocument();
const second = screen.getByRole("link", { name: /A2/ });
expect(within(second).getByText("Kritisch")).toBeInTheDocument();
expect(within(second).getByText("Weisellos · 2 Warnungen")).toBeInTheDocument();
expect(within(first).getByText("Aktiv")).toBeInTheDocument();
});
it("„Runde starten\" opens the first colony's inspection with the round param", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]),
);
renderCard();
await screen.findByRole("heading", { name: "Völker (2)" });
const start = screen.getByRole("button", { name: "Runde starten" });
expect(start).toHaveAttribute("href", "/inspections/new?colonyId=c-1&round=a-1");
await userEvent.click(start);
expect(readRound("user-1")).toMatchObject({
apiaryId: "a-1",
apiaryName: "Obstwiese",
colonies: [
{ id: "c-1", code: "A1" },
{ id: "c-2", code: "A2" },
],
});
});
it("„Runde starten\" is disabled without colonies", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(overview([]));
renderCard();
expect(await screen.findByText(/Noch keine Völker/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Runde starten" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Runde starten" })).not.toHaveAttribute("href");
});
it("shows a retryable error instead of an empty list when loading fails", async () => {
vi.mocked(fetchColonyOverview).mockRejectedValue(new Error("boom"));
renderCard();
expect(await screen.findByRole("alert")).toHaveTextContent("Völker konnten nicht geladen werden");
expect(screen.getByRole("button", { name: "Runde starten" })).toBeDisabled();
});
});
@@ -0,0 +1,107 @@
"use client";
import Link from "next/link";
import { ChevronRight, Play, Sprout } from "lucide-react";
import { useColonyOverview } from "@/lib/apiaries/use-colony-overview";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels";
import { colonyStatusLight } from "@/lib/colonies/status-light";
import { todayLocalIsoDate } from "@/lib/dates";
import { startRound } from "@/lib/rounds/round-state";
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
import { EmptyState } from "@/components/empty-state";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ApiaryRef {
id: string;
name: string;
}
/** The apiary page's one main action: walk through all colonies. */
export function StartRoundButton({ apiary }: { apiary: ApiaryRef }) {
const userId = useCurrentUserId();
const { data } = useColonyOverview(apiary.id);
const rows = data?.rows ?? [];
if (rows.length === 0) {
return (
<Button size="touch" disabled>
<Play aria-hidden />
Runde starten
</Button>
);
}
return (
<Button
size="touch"
nativeButton={false}
render={
<Link
href={`/inspections/new?colonyId=${encodeURIComponent(rows[0].id)}&round=${encodeURIComponent(apiary.id)}`}
onClick={() => {
if (userId) startRound(userId, apiary, rows);
}}
/>
}
>
<Play aria-hidden />
Runde starten
</Button>
);
}
export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) {
const query = useColonyOverview(apiary.id);
const overview = query.data;
const today = todayLocalIsoDate();
return (
<Card>
<CardHeader>
<CardTitle>
<h2>Völker ({overview?.rows.length ?? 0})</h2>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{query.isLoading ? <LoadingState what="Völker" /> : null}
{!query.isLoading && !overview ? (
<ErrorState what="Völker" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
) : null}
{overview && overview.rows.length === 0 ? (
<EmptyState
icon={Sprout}
title="Noch keine Völker an diesem Stand"
description="Sobald ein Volk hier steht, erscheint es mit seiner Ampel."
/>
) : null}
{overview?.rows.map((row) => {
const { light, reasons } = colonyStatusLight(row, {
today,
inspectionsVisible: overview.inspectionsVisible,
});
return (
<Link
key={row.id}
href={`/colonies/${row.id}`}
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{row.code}</span>
<StatusLightBadge light={light} />
<span className="text-sm text-muted-foreground">{COLONY_STATUS_LABELS[row.status]}</span>
</span>
{reasons.length > 0 ? (
<span className="text-sm text-muted-foreground">{reasons.join(" · ")}</span>
) : null}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</Link>
);
})}
</CardContent>
</Card>
);
}
@@ -20,6 +20,7 @@ const apiary: Apiary = {
latitude: 48.47,
longitude: 10.6,
address: "Hauptstr. 1",
publicLocationLabel: "Nordhessen",
status: "ACTIVE",
notes: "Schlüssel beim Nachbarn",
createdAt: "2026-01-01T00:00:00.000Z",
@@ -41,7 +42,7 @@ describe("ApiaryEditCard", () => {
</QueryClientProvider>,
);
for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen"]) {
for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen", "Ort für die Herkunftsseite"]) {
await userEvent.clear(screen.getByLabelText(new RegExp(`^${label}`)));
}
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
@@ -54,6 +55,7 @@ describe("ApiaryEditCard", () => {
longitude: null,
address: null,
notes: null,
publicLocationLabel: null,
});
});
});
@@ -14,6 +14,8 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PUBLIC_LOCATION_HINT } from "../new/apiary-form-values";
export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
const queryClient = useQueryClient();
const [name, setName] = useState(apiary.name);
@@ -23,6 +25,9 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
const [longitude, setLongitude] = useState(apiary.longitude?.toString() ?? "");
const [address, setAddress] = useState(apiary.address ?? "");
const [notes, setNotes] = useState(apiary.notes ?? "");
const [publicLocationLabel, setPublicLocationLabel] = useState(
apiary.publicLocationLabel ?? "",
);
const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -36,6 +41,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
longitude: emptyToNull(longitude, Number),
address: emptyToNull(address),
notes: emptyToNull(notes),
publicLocationLabel: emptyToNull(publicLocationLabel),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
@@ -159,6 +165,18 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
/>
)}
</Field>
<Field label="Ort für die Herkunftsseite" hint={PUBLIC_LOCATION_HINT}>
{({ id, describedBy }) => (
<Input
id={id}
className="h-11"
maxLength={100}
aria-describedby={describedBy}
value={publicLocationLabel}
onChange={(event) => setPublicLocationLabel(event.target.value)}
/>
)}
</Field>
<Field label="Notizen">
{({ id }) => (
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { FileText, MapPin, Plus } from "lucide-react";
@@ -10,7 +9,7 @@ import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
import { Button } from "@/components/ui/button";
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
import { ApiaryEditCard } from "./apiary-edit-card";
import { WeatherCard } from "./weather-card";
@@ -51,17 +50,13 @@ export default function ApiaryDetailPage() {
label={apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
/>
}
primaryAction={
<Button
size="touch"
nativeButton={false}
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
>
<Plus aria-hidden />
Ernte erfassen
</Button>
}
primaryAction={<StartRoundButton apiary={{ id: apiary.id, name: apiary.name }} />}
moreActions={[
{
label: "Ernte erfassen",
icon: Plus,
href: `/harvests/new?apiaryId=${apiary.id}`,
},
{
label: treatmentLogMutation.isPending
? "Bestandsbuch wird erstellt…"
@@ -83,6 +78,7 @@ export default function ApiaryDetailPage() {
/>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-4">
<ApiaryColoniesCard apiary={{ id: apiary.id, name: apiary.name }} />
<ApiaryEditCard key={apiary.id} apiary={apiary} />
<div>
<QrCodeDialog entityType="apiaries" id={apiary.id} label="QR-Code für den Stand" />
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { initialApiaryValues, toApiaryInput } from "./apiary-form-values";
describe("toApiaryInput", () => {
it("sends a filled public location label trimmed", () => {
const input = toApiaryInput({
...initialApiaryValues(),
name: "Hausgarten",
publicLocationLabel: " Nordhessen ",
});
expect(input.publicLocationLabel).toBe("Nordhessen");
});
it("sends an empty public location label as null", () => {
const input = toApiaryInput({ ...initialApiaryValues(), name: "Hausgarten" });
expect(input.publicLocationLabel).toBeNull();
});
});
@@ -1,6 +1,10 @@
import type { ApiaryInput } from "@/lib/apiaries/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
/** Hint of the public origin field, shared by the create form and the edit card. */
export const PUBLIC_LOCATION_HINT =
"Wird öffentlich auf der Herkunftsseite der Honiggläser gezeigt, z. B. „Nordhessen“. Nie die genaue Adresse eintragen. Leer lassen: Es erscheint nur das Land.";
/** Everything the form holds, as plain values that survive a draft save. */
export interface ApiaryFormValues {
name: string;
@@ -9,6 +13,7 @@ export interface ApiaryFormValues {
longitude: string;
address: string;
notes: string;
publicLocationLabel: string;
}
export type ApiaryFormErrors = Partial<Record<keyof ApiaryFormValues, string>>;
@@ -21,6 +26,7 @@ export function initialApiaryValues(): ApiaryFormValues {
longitude: "",
address: "",
notes: "",
publicLocationLabel: "",
};
}
@@ -44,5 +50,6 @@ export function toApiaryInput(values: ApiaryFormValues): ApiaryInput {
longitude: emptyToNull(values.longitude, Number),
address: emptyToNull(values.address),
notes: emptyToNull(values.notes),
publicLocationLabel: emptyToNull(values.publicLocationLabel),
};
}
@@ -68,6 +68,7 @@ describe("NewApiaryPage", () => {
longitude: null,
address: "Dorfstraße 1",
notes: null,
publicLocationLabel: null,
});
expect(push).toHaveBeenCalledWith("/apiaries/apiary-3");
});
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -24,6 +25,7 @@ import {
initialApiaryValues,
toApiaryInput,
validateApiary,
PUBLIC_LOCATION_HINT,
} from "./apiary-form-values";
const PERMISSION_DENIED = 1;
@@ -120,6 +122,12 @@ export default function NewApiaryPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/apiaries"
label="Zu den Bienenständen"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={MapPin}
title="Neuer Bienenstand"
@@ -229,6 +237,19 @@ export default function NewApiaryPage() {
/>
)}
</Field>
<Field label="Ort für die Herkunftsseite" hint={PUBLIC_LOCATION_HINT}>
{({ id, describedBy }) => (
<Input
id={id}
maxLength={100}
aria-describedby={describedBy}
value={values.publicLocationLabel}
onChange={(event) =>
set("publicLocationLabel", event.target.value)
}
/>
)}
</Field>
</FormSection>
<FormSection
@@ -31,6 +31,7 @@ describe("ApiariesPage", () => {
address: null,
status: "ACTIVE",
notes: null,
publicLocationLabel: null,
createdAt: "",
updatedAt: "",
deactivatedAt: null,
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RecordSelect } from "@/components/form/record-select";
import { honeyBatchOptionLabel } from "@/lib/honey-batches/labels";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -138,6 +139,12 @@ export default function NewBottlingBatchPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/bottling"
label="Zu den Abfüllungen"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Package}
title="Neue Abfüllung"
+14 -14
View File
@@ -2,41 +2,41 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Layers, ListTodo, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AuthUser } from "@/lib/auth/auth-context";
import { MoreSheet } from "./more-sheet";
import { bottomNavItems, isNavActive } from "./nav-config";
const ITEMS = [
{ label: "Start", href: "/dashboard", icon: LayoutDashboard },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{ label: "Suche", href: "/search", icon: Search },
];
export function BottomNav() {
export function BottomNav({ user }: { user: Pick<AuthUser, "permissions"> }) {
const pathname = usePathname();
return (
// Height and padding include the iOS home-indicator inset, so the labels
// stay tappable above it; --bottom-nav-height is shared with the content
// padding, StickySubmit and the quick-add button.
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-(--bottom-nav-height) border-t bg-background pb-(--safe-area-bottom) sm:hidden">
{ITEMS.map((item) => {
<nav
aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-30 flex h-(--bottom-nav-height) border-t bg-background pb-(--safe-area-bottom) sm:hidden"
>
{bottomNavItems().map((item) => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
const isActive = isNavActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
<Icon className="size-5" />
{item.label}
<Icon className="size-5" aria-hidden="true" />
{item.shortLabel ?? item.label}
</Link>
);
})}
<MoreSheet user={user} />
</nav>
);
}
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyInspectionsCard } from "./colony-inspections";
import { fetchColonyInspections } from "@/lib/inspections/api";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/inspections/api", () => ({
fetchColonyInspections: vi.fn(),
updateInspection: vi.fn(),
}));
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ColonyInspectionsCard colonyId="colony-1" />
</QueryClientProvider>,
);
}
const base = {
id: "i-1",
colonyId: "colony-1",
date: "2026-06-01T00:00:00.000Z",
colonyStrength: 8,
queenSeen: null,
healthIssues: null,
varroaIssues: null,
measures: null,
};
describe("ColonyInspectionsCard", () => {
it("shows the brood frame count when there is one", async () => {
vi.mocked(fetchColonyInspections).mockResolvedValue([{ ...base, broodFrames: 4 }] as never);
renderCard();
expect(await screen.findByText(/· 4 Brutwaben/)).toBeInTheDocument();
});
it("shows nothing about brood frames when the field is null or absent", async () => {
vi.mocked(fetchColonyInspections).mockResolvedValue([
{ ...base, broodFrames: null },
{ ...base, id: "i-2" },
] as never);
renderCard();
await screen.findAllByText("8 Waben");
expect(screen.queryByText(/Brutwaben/)).not.toBeInTheDocument();
expect(screen.queryByText(/undefined/)).not.toBeInTheDocument();
});
});
@@ -54,6 +54,7 @@ export function ColonyInspectionsCard({ colonyId }: { colonyId: string }) {
{inspection.colonyStrength !== null
? `${inspection.colonyStrength} Waben`
: "Stärke unbekannt"}
{inspection.broodFrames != null ? ` · ${inspection.broodFrames} Brutwaben` : ""}
{inspection.queenSeen !== null
? ` · Königin ${inspection.queenSeen ? "gesehen" : "nicht gesehen"}`
: ""}
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyVarroaCard } from "./colony-varroa";
import { fetchColonyVarroaMeasurements, fetchVarroaThreshold } from "@/lib/varroa/api";
import type { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
vi.mock("@/lib/varroa/api", () => ({
fetchColonyVarroaMeasurements: vi.fn(),
fetchVarroaThreshold: vi.fn(),
}));
vi.mock("./varroa-edit-dialog", () => ({ VarroaEditDialog: () => null }));
function measurement(
id: string,
date: string,
method: VarroaMethod,
calculatedValue: number | null,
): VarroaMeasurement {
return {
id,
colonyId: "c-1",
userId: "u-1",
date: `${date}T00:00:00.000Z`,
method,
durationMinutes: 4320,
miteCount: 12,
calculatedValue,
assessment: null,
notes: null,
createdAt: `${date}T00:00:00.000Z`,
};
}
const MEASUREMENTS = [
measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4),
measurement("b", "2026-06-08", "POWDERED_SUGAR", 99),
measurement("c", "2026-06-15", "NATURAL_MITE_FALL", 8),
];
function renderCard() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ColonyVarroaCard colonyId="c-1" />
</QueryClientProvider>,
);
}
describe("ColonyVarroaCard trend", () => {
beforeEach(() => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue(MEASUREMENTS);
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
});
it("the chart ignores powdered sugar counts", async () => {
const { container } = renderCard();
await screen.findByRole("img");
expect(container.querySelectorAll("svg circle")).toHaveLength(2);
});
it("draws the threshold line", async () => {
const { container } = renderCard();
expect(await screen.findByText("Schwelle 10/Tag")).toBeInTheDocument();
expect(container.querySelector("svg line[stroke-dasharray]")).not.toBeNull();
});
it("has an accessible summary", async () => {
renderCard();
await screen.findByText("Schwelle 10/Tag");
const chart = screen.getByRole("img");
expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Schwelle 10\/Tag/);
expect(chart).toHaveAccessibleDescription(/2 Messungen/);
});
it("shows only the value as text with fewer than two natural-fall points", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([MEASUREMENTS[0], MEASUREMENTS[1]]);
const { container } = renderCard();
expect(await screen.findByText(/Letzte Messung 4,0 Milben\/Tag am 01\.06\.2026/)).toBeInTheDocument();
expect(container.querySelector("svg circle")).toBeNull();
});
it("draws one marker per measurement even on the same day", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4),
measurement("b", "2026-06-01", "NATURAL_MITE_FALL", 6),
measurement("c", "2026-06-15", "NATURAL_MITE_FALL", 8),
]);
const { container } = renderCard();
await screen.findByRole("img");
expect(container.querySelectorAll("svg circle")).toHaveLength(3);
});
});
@@ -1,9 +1,13 @@
"use client";
import Link from "next/link";
import { useId } from "react";
import { Bug } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
import { VarroaMeasurement } from "@/lib/varroa/types";
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold";
import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
@@ -11,35 +15,117 @@ import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { VarroaEditDialog } from "./varroa-edit-dialog";
const CHART_WIDTH = 280;
const CHART_HEIGHT = 80;
const CHART_PADDING = 8;
const CHART_WIDTH = 300;
const CHART_HEIGHT = 120;
const PAD_X = 12;
const PAD_TOP = 12;
const PAD_BOTTOM = 12;
const MARKER_RADIUS = 4;
const LABEL_GAP = 4;
function VarroaTrendChart({ values }: { values: number[] }) {
if (values.length < 2) {
interface TrendPoint {
id: string;
time: number;
rate: number;
}
const DAY_FORMAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function formatDate(time: number): string {
return DAY_FORMAT.format(new Date(time));
}
/** Only the natural mite fall is a rate per day; other methods are not comparable. */
function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
return measurements
.filter((m) => m.method === "NATURAL_MITE_FALL" && m.calculatedValue !== null)
.map((m) => ({ id: m.id, time: new Date(m.date).getTime(), rate: m.calculatedValue as number }))
.sort((left, right) => left.time - right.time);
}
function summaryOf(last: TrendPoint, threshold: number | null): string {
const base = `Letzte Messung ${formatMitesPerDay(last.rate)} Milben/Tag am ${formatDate(last.time)}`;
return threshold === null ? base : `${base}, Schwelle ${formatThreshold(threshold)}/Tag`;
}
function VarroaTrendChart({
points,
threshold,
}: {
points: TrendPoint[];
threshold: number | null;
}) {
const titleId = useId();
const descId = useId();
const last = points[points.length - 1];
if (!last) {
return null;
}
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = max - min || 1;
const usableWidth = CHART_WIDTH - CHART_PADDING * 2;
const usableHeight = CHART_HEIGHT - CHART_PADDING * 2;
const points = values
.map((value, index) => {
const x = CHART_PADDING + (index / (values.length - 1)) * usableWidth;
const y = CHART_PADDING + usableHeight - ((value - min) / range) * usableHeight;
return `${x},${y}`;
})
.join(" ");
const summary = summaryOf(last, threshold);
if (points.length < 2) {
return <p className="font-medium">{summary}</p>;
}
const first = points[0];
const yMax = Math.max(...points.map((p) => p.rate), threshold ?? 0, 1);
const timeRange = last.time - first.time || 1;
const usableWidth = CHART_WIDTH - PAD_X * 2;
const usableHeight = CHART_HEIGHT - PAD_TOP - PAD_BOTTOM;
const xOf = (time: number) => PAD_X + ((time - first.time) / timeRange) * usableWidth;
const yOf = (rate: number) => PAD_TOP + usableHeight - (rate / yMax) * usableHeight;
const line = points.map((p) => `${xOf(p.time)},${yOf(p.rate)}`).join(" ");
return (
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary"
role="img"
aria-label="Verlauf der Varroamessungen"
aria-labelledby={titleId}
aria-describedby={descId}
>
<polyline points={points} fill="none" stroke="currentColor" strokeWidth={2} />
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
<desc id={descId}>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}`}
</desc>
{threshold !== null ? (
<g className="text-muted-foreground">
<line
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={yOf(threshold)}
y2={yOf(threshold)}
stroke="currentColor"
strokeWidth={1.5}
strokeDasharray="6 4"
/>
<text
x={CHART_WIDTH - PAD_X}
y={yOf(threshold) - LABEL_GAP}
textAnchor="end"
fontSize={10}
fill="currentColor"
>
{`Schwelle ${formatThreshold(threshold)}/Tag`}
</text>
</g>
) : null}
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
<circle
key={p.id}
cx={xOf(p.time)}
cy={yOf(p.rate)}
r={MARKER_RADIUS}
fill="currentColor"
className="stroke-background"
strokeWidth={2}
/>
))}
</svg>
);
}
@@ -50,6 +136,8 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
queryFn: () => fetchColonyVarroaMeasurements(colonyId),
});
const threshold = useVarroaThreshold();
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
@@ -77,11 +165,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
>
{(measurements) => (
<>
<VarroaTrendChart
values={measurements
.map((measurement) => measurement.miteCount ?? measurement.calculatedValue)
.filter((value): value is number => value !== null)}
/>
<VarroaTrendChart points={trendPoints(measurements)} threshold={threshold} />
{measurements.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
@@ -6,10 +6,18 @@ import { InspectionEditDialog } from "./inspection-edit-dialog";
import { updateInspection } from "@/lib/inspections/api";
import { fetchColonies } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { fetchDocuments } from "@/lib/documents/api";
import type { Inspection } from "@/lib/inspections/types";
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({ updateInspection: vi.fn() }));
vi.mock("@/lib/documents/api", () => ({
fetchDocuments: vi.fn(),
uploadDocument: vi.fn(),
updateDocument: vi.fn(),
deleteDocument: vi.fn(),
downloadDocument: vi.fn(),
}));
const inspection: Inspection = {
id: "i-1",
@@ -20,6 +28,7 @@ const inspection: Inspection = {
temperature: null,
colonyStrength: 8,
broodAmount: null,
broodFrames: null,
broodPattern: 3,
openBroodPresent: true,
cappedBroodPresent: true,
@@ -38,13 +47,13 @@ const inspection: Inspection = {
createdAt: "2026-06-01T00:00:00.000Z",
};
async function openDialog() {
async function openDialog(current: Inspection = inspection) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<InspectionEditDialog inspection={inspection} />
<InspectionEditDialog inspection={current} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
@@ -57,6 +66,28 @@ describe("InspectionEditDialog", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(updateInspection).mockReset();
vi.mocked(fetchDocuments).mockReset().mockResolvedValue([]);
});
it("has a „Fotos“ section with the inspection's files", async () => {
vi.mocked(fetchDocuments).mockResolvedValue([
{
id: "doc-1",
entityType: "INSPECTION",
entityId: "i-1",
fileName: "durchsicht-2026-06-01-1.jpg",
mimeType: "image/jpeg",
sizeBytes: 2048,
uploadedByUserId: "u-1",
createdAt: "2026-06-01T10:00:00.000Z",
},
]);
const dialog = await openDialog();
const photos = within(dialog).getByRole("group", { name: "Fotos" });
expect(await within(photos).findByText("durchsicht-2026-06-01-1.jpg")).toBeInTheDocument();
expect(fetchDocuments).toHaveBeenCalledWith("INSPECTION", "i-1");
expect(within(photos).getByRole("button", { name: "Datei hochladen" })).toBeInTheDocument();
});
it("sends the tapped rating as a number", async () => {
@@ -156,4 +187,61 @@ describe("InspectionEditDialog", () => {
await screen.findByText("Datum liegt in der Zukunft"),
).toBeInTheDocument();
});
it("shows Brutmenge (alt) only when the inspection has one", async () => {
await openDialog();
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
});
it("keeps the old free-text Brutmenge editable when the inspection has one", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, broodAmount: "4 Waben" });
expect(screen.getByLabelText(/Brutmenge \(alt\)/)).toHaveValue("4 Waben");
expect(
screen.getByText("Freitext aus früheren Durchsichten – neue Durchsichten zählen Brutwaben"),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ broodAmount: "4 Waben" });
});
it("clearing broodFrames sends null", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, broodFrames: 6 });
const field = screen.getByLabelText(/^Brutwaben/);
expect(field).toHaveValue("6");
await userEvent.clear(field);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ broodFrames: null });
});
it("stored swarmMood 5 shows display 1 selected", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, swarmMood: 5 });
const group = screen.getByRole("radiogroup", { name: /^Schwarmstimmung/ });
expect(within(group).getByRole("radio", { name: "1 von 5" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByText("1 = stark")).toBeInTheDocument();
expect(screen.getByText("5 = ruhig")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ swarmMood: 5 });
});
it("shows no Brutmenge (alt) when the field is absent from the payload", async () => {
const sparse = { ...inspection } as Partial<Inspection>;
delete sparse.broodAmount;
delete sparse.broodFrames;
await openDialog(sparse as Inspection);
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
});
});
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateInspection } from "@/lib/inspections/api";
import { Inspection } from "@/lib/inspections/types";
import type { RatingDirection } from "@/lib/forms/rating";
import { errorMessage } from "@/lib/forms/error-message";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -12,7 +13,9 @@ import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import { RatingScale } from "@/components/form/rating-scale";
import { ChoiceGroup } from "@/components/form/choice-group";
import { NumberStepper } from "@/components/form/number-stepper";
import { TriStateField } from "@/components/form/tri-state-field";
import { DocumentUploadCard } from "@/components/document-upload-card";
import {
Dialog,
DialogContent,
@@ -41,6 +44,7 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues {
temperature: inspection.temperature?.toString() ?? "",
colonyStrength: inspection.colonyStrength?.toString() ?? "",
broodAmount: inspection.broodAmount ?? "",
broodFrames: inspection.broodFrames?.toString() ?? "",
broodPattern: inspection.broodPattern,
// null = "Nicht geprüft" stays null; it must not become a "Nein".
openBroodPresent: inspection.openBroodPresent,
@@ -59,6 +63,9 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues {
varroaIssues: inspection.varroaIssues ?? "",
measures: inspection.measures ?? "",
notes: inspection.notes ?? "",
weatherAuto: false,
// A saved inspection takes its photos straight to the server (Fotos below).
photoSessionId: "",
};
}
@@ -67,10 +74,18 @@ const RATINGS: {
label: string;
low: string;
high: string;
direction?: RatingDirection;
}[] = [
{ key: "gentleness", label: "Sanftmut", low: "stechlustig", high: "sehr sanft" },
{ key: "combBehavior", label: "Wabensitz", low: "läuft weg", high: "sitzt ruhig" },
{ key: "swarmMood", label: "Schwarmstimmung", low: "keine", high: "stark" },
// Stored 1 = ruhig, 5 = stark; shown mirrored so the good end is on the right.
{
key: "swarmMood",
label: "Schwarmstimmung",
low: "ruhig",
high: "stark",
direction: "lower-is-better",
},
];
export function InspectionEditDialog({ inspection }: { inspection: Inspection }) {
@@ -187,31 +202,48 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
/>
)}
</Field>
<Field label="Brutmenge" hint="z. B. „4 Waben“">
<Field label="Brutwaben" hint="Waben mit Brut (offen oder verdeckelt)">
{({ id, describedBy }) => (
<Input
<NumberStepper
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.broodAmount}
onChange={(event) => set("broodAmount", event.target.value)}
value={values.broodFrames}
onChange={(count) => set("broodFrames", count)}
unitLabel="Waben mit Brut"
decrementLabel="Eine Brutwabe weniger"
incrementLabel="Eine Brutwabe mehr"
describedBy={describedBy}
/>
)}
</Field>
{inspection.broodAmount != null ? (
<Field
label="Brutmenge (alt)"
hint="Freitext aus früheren Durchsichten – neue Durchsichten zählen Brutwaben"
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.broodAmount}
onChange={(event) => set("broodAmount", event.target.value)}
/>
)}
</Field>
) : null}
</FormSection>
<FormSection step={3} title="Stärke & Verhalten">
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
{({ id, describedBy }) => (
<Input
<NumberStepper
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.colonyStrength}
onChange={(event) => set("colonyStrength", event.target.value)}
onChange={(count) => set("colonyStrength", count)}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
describedBy={describedBy}
/>
)}
</Field>
@@ -225,6 +257,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
onChange={(next) => set(rating.key, next)}
lowLabel={rating.low}
highLabel={rating.high}
direction={rating.direction}
/>
)}
</Field>
@@ -325,6 +358,10 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
</Field>
</FormSection>
<FormSection step={6} title="Fotos">
<DocumentUploadCard entityType="INSPECTION" entityId={inspection.id} />
</FormSection>
<DialogSubmitBar isPending={mutation.isPending} error={submitError} />
</form>
</DialogContent>
@@ -24,11 +24,11 @@ const measurement: VarroaMeasurement = {
createdAt: "2026-06-01T00:00:00.000Z",
};
async function openDialog() {
async function openDialog(item: VarroaMeasurement = measurement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<VarroaEditDialog measurement={measurement} />
<VarroaEditDialog measurement={item} />
</QueryClientProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
@@ -47,9 +47,10 @@ describe("VarroaEditDialog", () => {
it("sends emptied optional fields as null so a wrong value can be removed", async () => {
await openDialog();
for (const label of ["Milbenzahl", "Zeitraum (Minuten)", "Milben pro Tag", "Einschätzung", "Notizen"]) {
for (const label of ["Milbenzahl", "Milben pro Tag", "Einschätzung", "Notizen"]) {
await userEvent.clear(screen.getByLabelText(new RegExp(`^${label.replace(/[()]/g, "\\$&")}`)));
}
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
@@ -78,4 +79,24 @@ describe("VarroaEditDialog", () => {
notes: "Windel verrutscht",
});
});
it("a stored 2000 minutes is sent back unchanged", async () => {
await openDialog({ ...measurement, durationMinutes: 2000 });
expect(screen.getByText(/anders: 1,39 Tage \(2000 Minuten\)/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
expect(input).toMatchObject({ durationMinutes: 2000 });
});
it("keeps the minutes field for other methods", async () => {
await openDialog({ ...measurement, method: "WASHING_METHOD" });
expect(screen.getByLabelText(/^Zeitraum \(Minuten\)/)).toHaveValue(4320);
});
it("explains that a manual mites-per-day value takes precedence", async () => {
await openDialog();
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
});
});
@@ -27,6 +27,7 @@ import {
hasAnyValue,
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient();
@@ -72,6 +73,8 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
setSubmitError(errorMessage(error, "Varroamessung konnte nicht gespeichert werden.")),
});
const isNaturalFall = method === "NATURAL_MITE_FALL";
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
@@ -127,7 +130,10 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
/>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
{isNaturalFall ? (
<VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} />
) : null}
<div className={isNaturalFall ? undefined : "grid grid-cols-2 gap-3"}>
<Field label="Milbenzahl">
{({ id }) => (
<Input
@@ -141,21 +147,26 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
/>
)}
</Field>
<Field label="Zeitraum (Minuten)">
{({ id }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={durationMinutes}
onChange={(event) => setDurationMinutes(event.target.value)}
/>
)}
</Field>
{isNaturalFall ? null : (
<Field label="Zeitraum (Minuten)">
{({ id }) => (
<Input
id={id}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={durationMinutes}
onChange={(event) => setDurationMinutes(event.target.value)}
/>
)}
</Field>
)}
</div>
<Field label="Milben pro Tag">
<Field
label="Milben pro Tag"
hint="Wird beim Speichern aus Milbenzahl und Zeitraum neu berechnet – ein eigener, geänderter Wert hat Vorrang."
>
{({ id }) => (
<Input
id={id}
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -93,6 +94,12 @@ export default function NewColonyPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/colonies"
label="Zu den Völkern"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Layers}
title="Neues Volk"
@@ -10,6 +10,7 @@ import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -84,6 +85,12 @@ export default function NewEquipmentPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/equipment"
label="Zum Inventar"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Wrench}
title="Neuer Inventargegenstand"
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -120,6 +121,12 @@ export default function NewExtractionPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/extractions"
label="Zu den Schleuderungen"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Droplet}
title="Neue Schleuderung"
@@ -2,19 +2,19 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Utensils } from "lucide-react";
import { createFeeding } from "@/lib/feedings/api";
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueFeeding } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -52,6 +52,10 @@ const FEED_TYPE_OPTIONS = choiceOptions(FEED_TYPE_LABELS, FEED_TYPES);
export default function NewFeedingPage() {
const router = useRouter();
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const queryClient = useQueryClient();
@@ -60,7 +64,6 @@ export default function NewFeedingPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("feeding-new", values, dirty);
function set<K extends keyof FeedingFormValues>(
@@ -74,14 +77,20 @@ export default function NewFeedingPage() {
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createFeeding>[0]) =>
createFeeding(input, { idempotencyKey }),
onSuccess: (feeding) => {
const capture = useCaptureMutation({
create: createFeeding,
queue: queueFeeding,
failureMessage: "Fütterung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${input.colonyId}`);
return;
}
const feeding = outcome.result;
void queryClient.invalidateQueries({
queryKey: ["colonies", feeding.colonyId, "feedings"],
});
@@ -91,29 +100,10 @@ export default function NewFeedingPage() {
toast.success("Fütterung gespeichert");
router.push(`/colonies/${feeding.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Fütterung konnte nicht gespeichert werden.",
),
);
return;
}
void queueFeeding(variables, idempotencyKey).then(() => {
draft.clear();
toast.info(
"Kein Netzwerk – Fütterung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -123,7 +113,7 @@ export default function NewFeedingPage() {
?.focus();
return;
}
mutation.mutate(toFeedingInput(values));
capture.submit(toFeedingInput(values));
}
function restoreDraft() {
@@ -136,6 +126,12 @@ export default function NewFeedingPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Utensils}
title="Neue Fütterung"
@@ -266,12 +262,12 @@ export default function NewFeedingPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -279,9 +275,9 @@ export default function NewFeedingPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending
{capture.isPending
? "Wird gespeichert…"
: "Fütterung speichern"}
</Button>
@@ -2,14 +2,9 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Boxes, AlertTriangle } from "lucide-react";
import { Scissors, AlertTriangle } from "lucide-react";
import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels";
@@ -17,15 +12,14 @@ import { isLivingColony } from "@/lib/colonies/status";
import { createHarvest } from "@/lib/harvests/api";
import { fetchWithdrawalStatus } from "@/lib/treatments/api";
import { todayLocalIsoDate } from "@/lib/dates";
import { ApiError } from "@/lib/auth/api-client";
import { queueHarvest } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { RecordSelect } from "@/components/form/record-select";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -74,7 +68,6 @@ export default function NewHarvestPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("harvest-new", values, dirty);
const apiaryColonies = (colonies ?? []).filter(
@@ -122,38 +115,27 @@ export default function NewHarvestPage() {
);
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createHarvest>[0]) =>
createHarvest(input, { idempotencyKey }),
onSuccess: (harvest) => {
const capture = useCaptureMutation({
create: createHarvest,
queue: queueHarvest,
failureMessage: "Ernte konnte nicht gespeichert werden.",
onSaved: (outcome) => {
draft.clear();
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
toast.success("Ernte gespeichert");
router.push(`/harvests/${harvest.id}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(mutationError, "Ernte konnte nicht gespeichert werden."),
);
return;
}
void queueHarvest(variables, idempotencyKey).then(() => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Ernte wurde offline gespeichert und wird synchronisiert.",
);
router.push("/harvests");
});
return;
}
void queryClient.invalidateQueries({ queryKey: ["harvests"] });
toast.success("Ernte gespeichert");
router.push(`/harvests/${outcome.result.id}`);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -163,7 +145,7 @@ export default function NewHarvestPage() {
?.focus();
return;
}
mutation.mutate(toHarvestInput(values));
capture.submit(toHarvestInput(values));
}
function restoreDraft() {
@@ -176,8 +158,14 @@ export default function NewHarvestPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/harvests"
label="Zu den Ernten"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Boxes}
icon={Scissors}
title="Neue Ernte"
description="Pflicht sind Bienenstand, Datum und mindestens ein Volk."
/>
@@ -383,12 +371,12 @@ export default function NewHarvestPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -396,9 +384,9 @@ export default function NewHarvestPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending ? "Wird gespeichert…" : "Ernte speichern"}
{capture.isPending ? "Wird gespeichert…" : "Ernte speichern"}
</Button>
</StickySubmit>
</form>
@@ -83,6 +83,33 @@ describe("inspection form values", () => {
expect(input).toMatchObject({ weather: "sonnig", temperature: 0, colonyStrength: 8, swarmMood: 2 });
});
it("sends brood frames as a number, empty as null, and no legacy Brutmenge", () => {
const empty = toInspectionInput(initialInspectionValues("colony-1"));
const four = toInspectionInput({ ...initialInspectionValues("colony-1"), broodFrames: "4" });
expect(empty.broodFrames).toBeNull();
expect(four.broodFrames).toBe(4);
expect(empty.broodAmount).toBeNull();
});
it("starts weather as replaceable by the apiary weather", () => {
const values = initialInspectionValues("colony-1");
expect(values.broodFrames).toBe("");
expect(values.weatherAuto).toBe(true);
expect(toInspectionInput(values)).not.toHaveProperty("weatherAuto");
});
it("gives every new form its own photo session, kept in the draft but never sent", () => {
const first = initialInspectionValues("colony-1");
const second = initialInspectionValues("colony-1");
expect(first.photoSessionId).toMatch(/^[A-Za-z0-9_-]{16,}$/);
expect(second.photoSessionId).not.toBe(first.photoSessionId);
expect(JSON.parse(JSON.stringify(first)).photoSessionId).toBe(first.photoSessionId);
expect(toInspectionInput(first)).not.toHaveProperty("photoSessionId");
});
it("keeps 'not checked' through a draft or the offline queue (JSON)", () => {
const values = initialInspectionValues("colony-1");
@@ -2,6 +2,7 @@ import type { ChoiceOption } from "@/components/form/choice-group";
import type { createInspection } from "@/lib/inspections/api";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
export type FeedStatus = "ausreichend" | "niedrig" | "kritisch";
@@ -33,7 +34,10 @@ export interface InspectionFormValues {
weather: string;
temperature: string;
colonyStrength: string;
/** Legacy free text; only shown on edit when an old inspection has one. */
broodAmount: string;
/** Number of brood frames, "" = not counted. */
broodFrames: string;
broodPattern: number | null;
openBroodPresent: boolean | null;
cappedBroodPresent: boolean | null;
@@ -54,6 +58,17 @@ export interface InspectionFormValues {
varroaIssues: string;
measures: string;
notes: string;
/**
* true: weather and temperature come from the apiary weather prefill (or
* are still empty) and may be replaced when date or colony change. Any
* typing turns it off. Never sent to the API.
*/
weatherAuto: boolean;
/**
* The photos taken in this form (lib/photos/store.ts) - part of the draft,
* so a restored draft brings its photos back. Never sent to the API.
*/
photoSessionId: string;
}
export function initialInspectionValues(
@@ -66,6 +81,7 @@ export function initialInspectionValues(
temperature: "",
colonyStrength: "",
broodAmount: "",
broodFrames: "",
broodPattern: null,
openBroodPresent: null,
cappedBroodPresent: null,
@@ -81,6 +97,8 @@ export function initialInspectionValues(
varroaIssues: "",
measures: "",
notes: "",
weatherAuto: true,
photoSessionId: newIdempotencyKey(),
};
}
@@ -100,6 +118,7 @@ export function toInspectionInput(
temperature: emptyToNull(values.temperature, Number),
colonyStrength: emptyToNull(values.colonyStrength, Number),
broodAmount: emptyToNull(values.broodAmount),
broodFrames: emptyToNull(values.broodFrames, Number),
broodPattern: values.broodPattern ?? null,
openBroodPresent: values.openBroodPresent,
cappedBroodPresent: values.cappedBroodPresent,
@@ -1,19 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewInspectionPage from "./page";
import { createInspection } from "@/lib/inspections/api";
import { createInspection, fetchColonyInspections } from "@/lib/inspections/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { toast } from "sonner";
import type { Inspection } from "@/lib/inspections/types";
import type { DailyWeather } from "@/lib/weather/types";
import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
import { queueInspection } from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import {
attachSessionToInspection,
attachSessionToQueuedInspection,
discardSessionPhotos,
listSessionPhotos,
releaseSessionPhotos,
} from "@/lib/photos/store";
import { DRAFT_PREFIX } from "@/lib/forms/draft-storage";
import { initialInspectionValues } from "./inspection-form-values";
import { markColonyDone, readRound, startRound } from "@/lib/rounds/round-state";
const push = vi.fn();
let searchString = "colonyId=colony-1";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
useSearchParams: () => new URLSearchParams(searchString),
}));
// Without documents:upload the photo section stays hidden (most tests here).
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
}));
vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/apiaries/field-api")>()),
fetchColonyOverview: vi.fn(),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({
createInspection: vi.fn(),
fetchColonyInspections: vi.fn(),
}));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock("@/lib/offline/db", () => ({ queueInspection: vi.fn() }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5,
listSessionPhotos: vi.fn(),
addDraftPhoto: vi.fn(),
removePhoto: vi.fn(),
attachSessionToInspection: vi.fn(),
attachSessionToQueuedInspection: vi.fn(),
releaseSessionPhotos: vi.fn(),
discardSessionPhotos: vi.fn(),
}));
const NOTHING_SENT = { synced: 0, failed: 0, skippedForeign: 0 };
function renderPage() {
const client = new QueryClient({
@@ -29,17 +76,39 @@ function renderPage() {
describe("NewInspectionPage", () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
searchString = "colonyId=colony-1";
push.mockReset();
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(createInspection).mockReset();
vi.mocked(fetchColonyInspections).mockResolvedValue([]);
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.success).mockReset();
permissions = new Set();
vi.mocked(listSessionPhotos).mockReset().mockResolvedValue([]);
vi.mocked(attachSessionToInspection).mockReset().mockResolvedValue(undefined);
vi.mocked(attachSessionToQueuedInspection).mockReset().mockResolvedValue(undefined);
vi.mocked(releaseSessionPhotos).mockReset().mockResolvedValue(undefined);
vi.mocked(discardSessionPhotos).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
});
it("links back to the colony it was opened from", () => {
renderPage();
expect(screen.getByRole("link", { name: /Zum Volk/ })).toHaveAttribute(
"href",
"/colonies/colony-1",
);
});
it("marks what is required and keeps the extra fields folded", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(2);
expect(screen.queryByLabelText(/Wetter/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Honigraum/)).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Weitere Angaben/ }),
).toHaveAttribute("aria-expanded", "false");
@@ -139,4 +208,504 @@ describe("NewInspectionPage", () => {
expect(["INPUT", "SELECT", "TEXTAREA", "BUTTON"]).toContain(target?.tagName);
}
});
describe("capture helpers", () => {
const lastInspection: Inspection = {
id: "i-0",
colonyId: "colony-1",
userId: "u-1",
date: "2026-10-01T00:00:00.000Z",
weather: null,
temperature: null,
colonyStrength: 8,
broodAmount: null,
broodFrames: 5,
broodPattern: 3,
openBroodPresent: true,
cappedBroodPresent: null,
eggsPresent: true,
queenSeen: true,
queenCellsPresent: false,
swarmMood: 4,
gentleness: null,
combBehavior: null,
feedStatus: "niedrig",
honeySuperStatus: null,
healthIssues: null,
varroaIssues: null,
measures: null,
notes: null,
createdAt: "2026-10-01T00:00:00.000Z",
};
function checkedOf(group: string | RegExp) {
return within(screen.getByRole("radiogroup", { name: group }))
.getAllByRole("radio")
.find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent;
}
async function renderWithLast() {
vi.mocked(fetchColonyInspections).mockResolvedValue([lastInspection]);
renderPage();
await screen.findByText(/Letzte Durchsicht:/);
}
function dayWeather(date: string, overrides: Partial<DailyWeather> = {}): DailyWeather {
return {
date,
tempMaxC: 21.54,
tempMinC: 9,
precipitationMm: 0.4,
windSpeedMaxKmh: 12,
inspectionSuitable: true,
weatherCode: 2,
...overrides,
};
}
it("nothing is preselected from the last inspection", async () => {
await renderWithLast();
for (const group of [
"Königin gesehen",
"Stifte vorhanden",
"Offene Brut",
"Verdeckelte Brut",
"Weiselzellen",
]) {
expect(checkedOf(group)).toBe("Nicht geprüft");
}
expect(checkedOf(/^Futterstatus/)).toBe("Nicht geprüft");
for (const group of [/^Brutbild/, /^Sanftmut/, /^Wabensitz/, /^Schwarmstimmung/]) {
expect(checkedOf(group)).toBeUndefined();
}
expect(screen.getByLabelText(/^Brutwaben/)).toHaveValue("");
expect(screen.getByLabelText(/^Volksstärke/)).toHaveValue("");
});
it('„Letztes Mal: Ja · übernehmen" sets queenSeen on tap', async () => {
await renderWithLast();
await userEvent.click(screen.getByRole("button", { name: /übernehmen.*Königin gesehen/ }));
expect(checkedOf("Königin gesehen")).toBe("Ja");
expect(checkedOf("Stifte vorhanden")).toBe("Nicht geprüft");
});
it('„Alles wie letztes Mal" fills only untouched fields and can be undone', async () => {
await renderWithLast();
await userEvent.click(
within(screen.getByRole("radiogroup", { name: "Königin gesehen" })).getByRole("radio", {
name: "Nein",
}),
);
await userEvent.click(screen.getByRole("button", { name: "Alles wie letztes Mal" }));
expect(checkedOf("Königin gesehen")).toBe("Nein");
expect(checkedOf("Stifte vorhanden")).toBe("Ja");
expect(checkedOf("Verdeckelte Brut")).toBe("Nicht geprüft");
expect(screen.getByLabelText(/^Brutwaben/)).toHaveValue("5");
expect(checkedOf(/^Futterstatus/)).toBe("Niedrig");
const [message, options] = vi.mocked(toast.success).mock.calls[0];
expect(message).toMatch(/^\d+ Werte übernommen$/);
expect(options?.action).toMatchObject({ label: "Rückgängig" });
const action = options?.action as unknown as { onClick: () => void };
act(() => action.onClick());
expect(checkedOf("Königin gesehen")).toBe("Nein");
expect(checkedOf("Stifte vorhanden")).toBe("Nicht geprüft");
expect(screen.getByLabelText(/^Brutwaben/)).toHaveValue("");
expect(checkedOf(/^Futterstatus/)).toBe("Nicht geprüft");
});
it("fills weather and temperature from the apiary weather for the chosen date", async () => {
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [dayWeather(todayLocalIsoDate())],
});
renderPage();
await waitFor(() =>
expect(screen.getByLabelText(/^Wetter/)).toHaveValue(
"Teilweise bewölkt, 0.4 mm, Wind 12 km/h",
),
);
expect(screen.getByLabelText(/^Temperatur/)).toHaveValue(21.5);
expect(
screen.getByText("Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"),
).toBeInTheDocument();
});
it("re-fills auto weather when the date changes but never overwrites typed weather", async () => {
const now = new Date();
const other = toLocalIsoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [
dayWeather(todayLocalIsoDate()),
dayWeather(other, { weatherCode: 61, tempMaxC: 14 }),
],
});
renderPage();
await waitFor(() =>
expect((screen.getByLabelText(/^Wetter/) as HTMLInputElement).value).toMatch(/^Teilweise/),
);
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: other } });
await waitFor(() =>
expect((screen.getByLabelText(/^Wetter/) as HTMLInputElement).value).toMatch(/^Regen/),
);
expect(screen.getByLabelText(/^Temperatur/)).toHaveValue(14);
await userEvent.clear(screen.getByLabelText(/^Wetter/));
await userEvent.type(screen.getByLabelText(/^Wetter/), "windig");
fireEvent.change(screen.getByLabelText(/^Datum/), {
target: { value: todayLocalIsoDate() },
});
await waitFor(() =>
expect(screen.getByLabelText(/^Datum/)).toHaveValue(todayLocalIsoDate()),
);
expect(screen.getByLabelText(/^Wetter/)).toHaveValue("windig");
});
it('swarm mood: tapping display 5 („ruhig") sends swarmMood 1', async () => {
vi.mocked(createInspection).mockResolvedValue({
id: "i-1",
colonyId: "colony-1",
} as never);
renderPage();
expect(screen.getByText("1 = stark")).toBeInTheDocument();
expect(screen.getByText("5 = ruhig")).toBeInTheDocument();
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Schwarmstimmung/ })).getByRole("radio", {
name: "5 von 5",
}),
);
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ swarmMood: 1 });
});
it("brood frames stepper sends broodFrames 5", async () => {
vi.mocked(createInspection).mockResolvedValue({
id: "i-1",
colonyId: "colony-1",
} as never);
renderPage();
const plus = screen.getByRole("button", { name: /Brutwabe mehr/ });
for (let i = 0; i < 5; i += 1) await userEvent.click(plus);
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({
broodFrames: 5,
broodAmount: null,
});
});
it("has no Brutmenge field", () => {
renderPage();
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
});
it("announces politely when auto weather is refreshed or missing for the day", async () => {
const now = new Date();
const other = toLocalIsoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2));
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [dayWeather(todayLocalIsoDate())],
});
renderPage();
const live = await screen.findByRole("status");
expect(live).toHaveAttribute("aria-live", "polite");
await waitFor(() => expect(live).toHaveTextContent("Wetter aktualisiert"));
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: other } });
await waitFor(() => expect(live).toHaveTextContent("Keine Wetterdaten für diesen Tag"));
expect(screen.getByLabelText(/^Wetter/)).toHaveValue("");
});
it("changes the announcement on every automatic update so it is read again", async () => {
const now = new Date();
const other = toLocalIsoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));
vi.mocked(fetchApiaryWeather).mockResolvedValue({
daily: [dayWeather(todayLocalIsoDate()), dayWeather(other, { weatherCode: 61, tempMaxC: 14 })],
});
renderPage();
const live = await screen.findByRole("status");
await waitFor(() => expect(live).toHaveTextContent("Wetter aktualisiert"));
const first = live.textContent;
fireEvent.change(screen.getByLabelText(/^Datum/), { target: { value: other } });
await waitFor(() => expect(live.textContent).not.toBe(first));
expect(live).toHaveTextContent("Wetter aktualisiert");
expect(live).toHaveTextContent("14.0");
});
it("stays silent once the weather was typed by hand", async () => {
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
renderPage();
await userEvent.type(screen.getByLabelText(/^Wetter/), "windig");
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
});
describe("in a stand round", () => {
const apiary = { id: "apiary-1", name: "Obstwiese" };
const colonies = [
{ id: "colony-1", code: "B2" },
{ id: "colony-2", code: "B3" },
{ id: "colony-3", code: "B4" },
];
function overviewOf(ids: string[]) {
return {
apiaryId: "apiary-1",
inspectionsVisible: true,
rows: colonies.filter((colony) => ids.includes(colony.id)).map((colony) => ({ ...colony })),
};
}
beforeEach(() => {
searchString = "colonyId=colony-1&round=apiary-1";
vi.mocked(fetchColonies).mockResolvedValue(
colonies.map((colony) => ({ ...colony, status: "ACTIVE", apiaryId: "apiary-1" })) as never,
);
vi.mocked(fetchColonyOverview).mockResolvedValue(
overviewOf(["colony-1", "colony-2", "colony-3"]) as never,
);
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
vi.mocked(queueInspection).mockReset();
startRound("user-1", apiary, colonies);
});
async function save() {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
it("shows where in the round the beekeeper is", async () => {
renderPage();
expect(await screen.findByText("Runde Obstwiese: 1 von 3")).toBeInTheDocument();
});
it("makes the chip and the done-mark follow the colony selected in the form", async () => {
vi.mocked(fetchColonies).mockResolvedValue([
...colonies.map((colony) => ({ ...colony, status: "ACTIVE", apiaryId: "apiary-1" })),
{ id: "colony-9", code: "X9", status: "ACTIVE", apiaryId: "apiary-9" },
] as never);
markColonyDone("user-1", "colony-2");
renderPage();
expect(await screen.findByText("Runde Obstwiese: 2 von 3")).toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "B3" }));
expect(await screen.findByText("Runde Obstwiese: 1 von 3")).toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "X9" }));
await waitFor(() => expect(screen.queryByText(/^Runde /)).not.toBeInTheDocument());
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(await screen.findByRole("option", { name: "B4" }));
expect(await screen.findByText("Runde Obstwiese: 2 von 3")).toBeInTheDocument();
await save();
await screen.findByText("Gespeichert");
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-2", "colony-3"]);
});
it("after save shows „Weiter mit B3“ instead of going to the colony", async () => {
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await save();
expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument();
expect(screen.getByText("Gespeichert")).toBeInTheDocument();
expect(push).not.toHaveBeenCalled();
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-1"]);
await userEvent.click(screen.getByRole("button", { name: "Weiter mit B3" }));
expect(push).toHaveBeenCalledWith("/inspections/new?colonyId=colony-2&round=apiary-1");
});
it("skips a colony that left the apiary during the round", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(
overviewOf(["colony-1", "colony-3"]) as never,
);
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await waitFor(() => expect(fetchColonyOverview).toHaveBeenCalled());
await save();
expect(await screen.findByRole("button", { name: "Weiter mit B4" })).toBeInTheDocument();
});
it("„Runde beenden“ ends the round", async () => {
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await save();
await userEvent.click(await screen.findByRole("button", { name: "Runde beenden" }));
expect(readRound("user-1")).toBeNull();
expect(toast.info).toHaveBeenCalledWith("Runde beendet – 1 von 3 Völkern");
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
});
it("an offline save also advances the round", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockResolvedValue(undefined);
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await save();
expect(await screen.findByRole("button", { name: "Weiter mit B3" })).toBeInTheDocument();
expect(queueInspection).toHaveBeenCalled();
expect(readRound("user-1")?.doneColonyIds).toEqual(["colony-1"]);
expect(push).not.toHaveBeenCalled();
});
it("finishes the round when nothing is left to do", async () => {
vi.mocked(fetchColonyOverview).mockResolvedValue(overviewOf(["colony-1"]) as never);
renderPage();
await screen.findByText("Runde Obstwiese: 1 von 3");
await waitFor(() => expect(fetchColonyOverview).toHaveBeenCalled());
await save();
expect(await screen.findByText("Runde abgeschlossen – alle 1 Völker")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Zum Stand" }));
expect(readRound("user-1")).toBeNull();
expect(push).toHaveBeenCalledWith("/apiaries/apiary-1");
});
it("behaves as usual when the round belongs to another apiary", async () => {
searchString = "colonyId=colony-1&round=apiary-9";
renderPage();
await save();
await waitFor(() => expect(push).toHaveBeenCalledWith("/colonies/colony-1"));
expect(screen.queryByText(/^Runde /)).not.toBeInTheDocument();
expect(readRound("user-1")?.doneColonyIds).toEqual([]);
});
});
describe("photos", () => {
const DRAFT_KEY = `${DRAFT_PREFIX}user-1:inspection-new`;
beforeEach(() => {
permissions = new Set(["documents:upload"]);
vi.mocked(queueInspection).mockReset();
});
afterEach(() => {
permissions = new Set();
});
/** The photo session the form currently shows. */
function shownSession(): string {
const calls = vi.mocked(listSessionPhotos).mock.calls;
return calls[calls.length - 1][0];
}
function storeDraft(photoSessionId: string) {
window.localStorage.setItem(
DRAFT_KEY,
JSON.stringify({
savedAt: Date.now(),
values: { ...initialInspectionValues("colony-1"), photoSessionId, colonyStrength: "8" },
}),
);
}
async function save() {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
it("offers „Foto aufnehmen“ with the camera", async () => {
renderPage();
expect(await screen.findByRole("button", { name: "Foto aufnehmen" })).toBeInTheDocument();
expect(document.querySelector('input[type="file"]')).toHaveAttribute(
"capture",
"environment",
);
});
it("hands the photos to the saved inspection and sends them in the background", async () => {
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
await save();
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(attachSessionToInspection).toHaveBeenCalledWith(shownSession(), "i-1");
expect(vi.mocked(attachSessionToInspection).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
expect(attachSessionToQueuedInspection).not.toHaveBeenCalled();
});
it("ties the photos to the queued inspection before it is queued", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockResolvedValue(undefined);
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
await save();
await waitFor(() => expect(queueInspection).toHaveBeenCalled());
const [, idempotencyKey] = vi.mocked(queueInspection).mock.calls[0];
expect(attachSessionToQueuedInspection).toHaveBeenCalledWith(shownSession(), idempotencyKey);
// A sync that sends the inspection right away already finds its photos.
expect(vi.mocked(attachSessionToQueuedInspection).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(queueInspection).mock.invocationCallOrder[0],
);
expect(attachSessionToInspection).not.toHaveBeenCalled();
expect(releaseSessionPhotos).not.toHaveBeenCalled();
});
it("gives the photos back to the form when the inspection cannot be queued", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
vi.mocked(queueInspection).mockRejectedValue(new Error("QuotaExceededError"));
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
await save();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Durchsicht konnte nicht gespeichert werden.",
);
expect(releaseSessionPhotos).toHaveBeenCalledWith(shownSession());
});
it("„Verwerfen“ of an earlier draft also deletes its photos", async () => {
storeDraft("draft-session-0001");
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
expect(discardSessionPhotos).toHaveBeenCalledWith("draft-session-0001");
expect(window.localStorage.getItem(DRAFT_KEY)).toBeNull();
});
it("brings the photos of a restored draft back", async () => {
storeDraft("draft-session-0001");
renderPage();
await waitFor(() => expect(listSessionPhotos).toHaveBeenCalled());
const freshSession = shownSession();
await userEvent.click(await screen.findByRole("button", { name: "Weitermachen" }));
await waitFor(() => expect(shownSession()).toBe("draft-session-0001"));
expect(screen.getByDisplayValue("8")).toBeInTheDocument();
// The fresh form's own photos give way to the restored entry, like its values.
expect(discardSessionPhotos).toHaveBeenCalledWith(freshSession);
expect(discardSessionPhotos).not.toHaveBeenCalledWith("draft-session-0001");
});
});
});
@@ -1,19 +1,44 @@
"use client";
import { FormEvent, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, ClipboardCheck } from "lucide-react";
import { createInspection } from "@/lib/inspections/api";
import { ApiError } from "@/lib/auth/api-client";
import { fetchColonies } from "@/lib/colonies/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { weatherPrefillFor } from "@/lib/weather/describe";
import {
daysSince,
previousLabel,
previousValueOf,
takeOverAll,
undoTakeOver,
type TakeoverField,
} from "@/lib/inspections/takeover";
import { useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { CreateInspectionInput, Inspection } from "@/lib/inspections/types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { queueInspection } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { syncPendingEntries } from "@/lib/offline/sync";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import {
attachSessionToInspection,
attachSessionToQueuedInspection,
discardSessionPhotos,
releaseSessionPhotos,
} from "@/lib/photos/store";
import { toCalendarDay, todayLocalIsoDate } from "@/lib/dates";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { useStandRound } from "@/lib/rounds/use-stand-round";
import type { StandRound } from "@/lib/rounds/round-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -22,8 +47,11 @@ import { FormSection } from "@/components/form/form-section";
import { RatingScale } from "@/components/form/rating-scale";
import { ChoiceGroup } from "@/components/form/choice-group";
import { TriStateField } from "@/components/form/tri-state-field";
import { NumberStepper } from "@/components/form/number-stepper";
import { PreviousValueHint } from "@/components/form/previous-value-hint";
import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { PhotoCaptureField } from "@/components/photos/photo-capture-field";
import {
FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS,
@@ -32,68 +60,225 @@ import {
toInspectionInput,
} from "./inspection-form-values";
export default function NewInspectionPage() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const UNDO_TOAST_MS = 8000;
const WEATHER_UPDATED = "Wetter aktualisiert";
const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag";
const TAKEOVER_LABELS: Record<TakeoverField, string> = {
queenSeen: "Königin gesehen",
eggsPresent: "Stifte vorhanden",
openBroodPresent: "Offene Brut",
cappedBroodPresent: "Verdeckelte Brut",
queenCellsPresent: "Weiselzellen",
broodPattern: "Brutbild",
broodFrames: "Brutwaben",
colonyStrength: "Volksstärke",
gentleness: "Sanftmut",
combBehavior: "Wabensitz",
swarmMood: "Schwarmstimmung",
feedStatus: "Futterstatus",
honeySuperStatus: "Honigraum",
};
type SetValues = (update: (current: InspectionFormValues) => InspectionFormValues) => void;
/** "2026-10-01T00:00:00.000Z" -> "01.10.2026", whatever the phone's time zone. */
function formatGermanDay(isoDate: string): string {
const [year, month, day] = toCalendarDay(isoDate).split("-");
return `${day}.${month}.${year}`;
}
function daysAgoText(days: number): string {
if (days <= 0) return "heute";
return days === 1 ? "vor 1 Tag" : `vor ${days} Tagen`;
}
/**
* Fills weather and temperature from the apiary's weather data for the chosen
* date - visible and editable. Only while the fields are still the automatic
* ones (`weatherAuto`): once the beekeeper types, they are left alone.
*/
function useWeatherAutofill(values: InspectionFormValues, setValues: SetValues): string {
const { data: colonies } = useQuery({
queryKey: ["colonies"],
queryFn: () => fetchColonies(),
});
const apiaryId = colonies?.find((colony) => colony.id === values.colonyId)?.apiaryId ?? "";
const { data: weather } = useQuery({
queryKey: ["apiaries", apiaryId, "weather"],
queryFn: () => fetchApiaryWeather(apiaryId),
enabled: apiaryId !== "",
});
const prefill = useMemo(
() => (weather ? weatherPrefillFor(weather.daily, values.date) : null),
[weather, values.date],
);
const { weatherAuto, weather: text, temperature } = values;
useEffect(() => {
if (!weatherAuto) return;
const nextWeather = prefill?.weather ?? "";
const nextTemperature = prefill?.temperature ?? "";
if (text === nextWeather && temperature === nextTemperature) return;
setValues((current) =>
current.weatherAuto
? { ...current, weather: nextWeather, temperature: nextTemperature }
: current,
);
}, [weatherAuto, text, temperature, prefill, setValues]);
// Hand-typed weather is the beekeeper's own: nothing to announce about it.
if (!weatherAuto || !weather || !values.date) return "";
// The date (and values) are part of the text: it must change with every
// update, or a screen reader would not read the new result again.
const day = formatGermanDay(values.date);
if (!prefill) return `${WEATHER_MISSING} (${day})`;
const summary = [prefill.weather, prefill.temperature ? `${prefill.temperature} °C` : ""]
.filter(Boolean)
.join(", ");
return `${WEATHER_UPDATED} (${day}): ${summary}`;
}
/**
* Offline save: the form's photos are tied to the queue entry's key before
* the entry exists, so a sync that sends the inspection right away already
* finds them. If queueing fails, they belong to the open form again.
*/
async function queueInspectionWithPhotos(
photoSessionId: string,
input: CreateInspectionInput,
idempotencyKey: string,
ownerUserId: string,
): Promise<void> {
await attachSessionToQueuedInspection(photoSessionId, idempotencyKey);
try {
await queueInspection(input, idempotencyKey, ownerUserId);
} catch (error) {
await releaseSessionPhotos(photoSessionId);
throw error;
}
}
/**
* Online save: the photos belong to the saved inspection and go up in the
* background - inside a sync run, so a logout's cancelSync() stops them too.
*/
async function sendPhotosOf(
photoSessionId: string,
inspectionId: string,
userId: string | null,
): Promise<void> {
await attachSessionToInspection(photoSessionId, inspectionId);
if (userId) {
await syncPendingEntries(userId);
}
}
interface LastInspectionBarProps {
last: Inspection;
onTakeOverAll: () => void;
}
function LastInspectionBar({ last, onTakeOverAll }: LastInspectionBarProps) {
const days = daysSince(last.date, todayLocalIsoDate());
return (
<div className="flex flex-col items-start gap-2 rounded-xl bg-muted/50 p-3">
<p className="text-sm">
Letzte Durchsicht: {formatGermanDay(last.date)} · {daysAgoText(days)}
</p>
<Button type="button" variant="outline" className="min-h-11" onClick={onTakeOverAll}>
Alles wie letztes Mal
</Button>
</div>
);
}
interface InspectionCaptureFormProps {
initialColonyId: string;
/** Apiary of the stand round this form was opened from, if any. */
roundApiaryId: string | null;
backTarget: { href: string; label: string };
}
interface RoundSaved {
round: StandRound;
savedOffline: boolean;
}
/** "Runde Stand: k von n" - k counts this colony unless it is already done. */
function roundChipText(round: StandRound, colonyId: string): string {
const position = round.doneColonyIds.length + (round.doneColonyIds.includes(colonyId) ? 0 : 1);
return `Runde ${round.apiaryName}: ${position} von ${round.colonies.length}`;
}
function InspectionCaptureForm({
initialColonyId,
roundApiaryId,
backTarget,
}: InspectionCaptureFormProps) {
const router = useRouter();
const queryClient = useQueryClient();
const userId = useCurrentUserId();
const [values, setValues] = useState<InspectionFormValues>(() =>
initialInspectionValues(searchParams.get("colonyId") ?? ""),
initialInspectionValues(initialColonyId),
);
const [dirty, setDirty] = useState(false);
const [colonyError, setColonyError] = useState<string | null>(null);
const [dateError, setDateError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("inspection-new", values, dirty);
const { inspection: last } = useLastInspection(values.colonyId);
const weatherNotice = useWeatherAutofill(values, setValues);
const stand = useStandRound(roundApiaryId, values.colonyId);
const [roundSaved, setRoundSaved] = useState<RoundSaved | null>(null);
function set<K extends keyof InspectionFormValues>(
key: K,
value: InspectionFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
const typedWeather = key === "weather" || key === "temperature";
setValues((current) => ({
...current,
[key]: value,
...(typedWeather ? { weatherAuto: false } : {}),
}));
setDirty(true);
if (key === "colonyId") setColonyError(null);
if (key === "date") setDateError(null);
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createInspection>[0]) =>
createInspection(input, { idempotencyKey }),
onSuccess: (inspection) => {
const capture = useCaptureMutation({
create: createInspection,
queue: (input: CreateInspectionInput, idempotencyKey, ownerUserId) =>
queueInspectionWithPhotos(values.photoSessionId, input, idempotencyKey, ownerUserId),
failureMessage: "Durchsicht konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
void queryClient.invalidateQueries({
queryKey: ["colonies", inspection.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
router.push(`/colonies/${inspection.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Durchsicht konnte nicht gespeichert werden.",
),
);
return;
}
void queueInspection(variables, idempotencyKey).then(() => {
draft.clear();
const savedOffline = outcome.via === "queued";
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Durchsicht wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
} else {
void queryClient.invalidateQueries({
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
void sendPhotosOf(values.photoSessionId, outcome.result.id, userId).catch(() =>
toast.error("Fotos konnten nicht gesendet werden."),
);
}
const advanced = stand.round ? stand.markDone(input.colonyId) : null;
if (advanced) {
setRoundSaved({ round: advanced, savedOffline });
return;
}
router.push(`/colonies/${input.colonyId}`);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
if (!values.colonyId) {
setColonyError("Bitte ein Volk auswählen.");
document.getElementById("inspection-colony")?.focus();
@@ -104,22 +289,130 @@ export default function NewInspectionPage() {
document.getElementById("inspection-date")?.focus();
return;
}
mutation.mutate(toInspectionInput(values));
capture.submit(toInspectionInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
// A draft from before photos existed has no session: the form keeps its own.
const photoSessionId = restored.photoSessionId ?? values.photoSessionId;
if (photoSessionId !== values.photoSessionId) {
// Like the typed values, photos taken before restoring give way to the
// restored entry. Unsaved photos left behind are purged after 3 days.
void discardSessionPhotos(values.photoSessionId).catch(() => undefined);
}
// A draft from before these fields existed lacks them.
setValues({
...initialInspectionValues(restored.colonyId),
...restored,
photoSessionId,
weatherAuto: restored.weatherAuto ?? (restored.weather === "" && restored.temperature === ""),
});
setDirty(true);
}
}
/** „Verwerfen" of an earlier draft takes the photos taken in it along. */
function discardDraft() {
const draftPhotoSessionId = draft.pending?.values.photoSessionId;
draft.discard();
if (draftPhotoSessionId) {
// Unsaved photos left behind are purged after 3 days anyway.
void discardSessionPhotos(draftPhotoSessionId).catch(() => undefined);
}
}
function takeOver(field: TakeoverField, value: NonNullable<ReturnType<typeof previousValueOf>>) {
setValues((current) => ({ ...current, [field]: value }));
setDirty(true);
}
function takeOverEverything() {
if (!last) return;
const { values: next, taken } = takeOverAll(values, last);
if (taken.length === 0) {
toast.info("Nichts zu übernehmen – alles schon ausgefüllt.");
return;
}
const before = values;
setValues(next);
setDirty(true);
toast.success(`${taken.length} Werte übernommen`, {
action: {
label: "Rückgängig",
onClick: () => setValues((current) => undoTakeOver(current, before, next, taken)),
},
duration: UNDO_TOAST_MS,
});
}
/** "Letztes Mal" for one field, or null if there is nothing new to offer. */
function previousFor(field: TakeoverField) {
if (!last) return null;
const previous = previousValueOf(last, field);
if (previous === null || values[field] === previous) return null;
return {
valueLabel: previousLabel(field, previous),
onTakeOver: () => takeOver(field, previous),
};
}
function hintFor(field: TakeoverField) {
const previous = previousFor(field);
return previous ? (
<PreviousValueHint fieldLabel={TAKEOVER_LABELS[field]} {...previous} />
) : null;
}
if (roundSaved) {
const { round } = roundSaved;
const next = stand.next(round);
const finishRound = () => {
stand.end();
router.push(`/apiaries/${encodeURIComponent(round.apiaryId)}`);
};
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<RoundNextPanel
next={next}
doneCount={round.doneColonyIds.length}
savedOffline={roundSaved.savedOffline}
onNext={() =>
next &&
router.push(
`/inspections/new?colonyId=${encodeURIComponent(next.id)}&round=${encodeURIComponent(round.apiaryId)}`,
)
}
onEnd={() => {
if (next) {
toast.info(
`Runde beendet – ${round.doneColonyIds.length} von ${round.colonies.length} Völkern`,
);
}
finishRound();
}}
/>
</main>
);
}
// Same rule as the backend automation: only an explicit "Nein" counts.
const noQueenSigns = values.queenSeen === false && values.eggsPresent === false;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
{stand.round ? (
<p className="w-fit rounded-full bg-muted px-3 py-1 text-sm font-medium">
{roundChipText(stand.round, values.colonyId)}
</p>
) : null}
<PageHeader
icon={ClipboardCheck}
title="Neue Durchsicht"
@@ -130,7 +423,7 @@ export default function NewInspectionPage() {
<DraftBanner
savedAt={draft.pending.savedAt}
onRestore={restoreDraft}
onDiscard={draft.discard}
onDiscard={discardDraft}
/>
) : null}
<Card className="max-w-lg">
@@ -161,6 +454,7 @@ export default function NewInspectionPage() {
/>
)}
</Field>
{last ? <LastInspectionBar last={last} onTakeOverAll={takeOverEverything} /> : null}
</FormSection>
<FormSection
@@ -175,6 +469,7 @@ export default function NewInspectionPage() {
label={observation.label}
value={values[observation.key]}
onChange={(answer) => set(observation.key, answer)}
previous={previousFor(observation.key)}
/>
))}
</div>
@@ -190,26 +485,34 @@ export default function NewInspectionPage() {
) : null}
<Field label="Brutbild" group>
{({ id, labelId, describedBy }) => (
<RatingScale
id={id}
labelledBy={labelId}
describedBy={describedBy}
value={values.broodPattern}
onChange={(rating) => set("broodPattern", rating)}
lowLabel="lückenhaft"
highLabel="geschlossen"
/>
<>
<RatingScale
id={id}
labelledBy={labelId}
describedBy={describedBy}
value={values.broodPattern}
onChange={(rating) => set("broodPattern", rating)}
lowLabel="lückenhaft"
highLabel="geschlossen"
/>
{hintFor("broodPattern")}
</>
)}
</Field>
<Field label="Brutmenge" hint="z. B. „4 Waben“">
<Field label="Brutwaben" hint="Waben mit Brut (offen oder verdeckelt)">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.broodAmount}
onChange={(event) => set("broodAmount", event.target.value)}
/>
<>
<NumberStepper
id={id}
value={values.broodFrames}
onChange={(count) => set("broodFrames", count)}
unitLabel="Waben mit Brut"
decrementLabel="Eine Brutwabe weniger"
incrementLabel="Eine Brutwabe mehr"
describedBy={describedBy}
/>
{hintFor("broodFrames")}
</>
)}
</Field>
</FormSection>
@@ -217,54 +520,64 @@ export default function NewInspectionPage() {
<FormSection step={3} title="Stärke & Verhalten">
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.colonyStrength}
onChange={(event) =>
set("colonyStrength", event.target.value)
}
/>
<>
<NumberStepper
id={id}
value={values.colonyStrength}
onChange={(count) => set("colonyStrength", count)}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
describedBy={describedBy}
/>
{hintFor("colonyStrength")}
</>
)}
</Field>
<Field label="Sanftmut" group>
{({ id, labelId }) => (
<RatingScale
id={id}
labelledBy={labelId}
value={values.gentleness}
onChange={(rating) => set("gentleness", rating)}
lowLabel="stechlustig"
highLabel="sehr sanft"
/>
<>
<RatingScale
id={id}
labelledBy={labelId}
value={values.gentleness}
onChange={(rating) => set("gentleness", rating)}
lowLabel="stechlustig"
highLabel="sehr sanft"
/>
{hintFor("gentleness")}
</>
)}
</Field>
<Field label="Wabensitz" group>
{({ id, labelId }) => (
<RatingScale
id={id}
labelledBy={labelId}
value={values.combBehavior}
onChange={(rating) => set("combBehavior", rating)}
lowLabel="läuft weg"
highLabel="sitzt ruhig"
/>
<>
<RatingScale
id={id}
labelledBy={labelId}
value={values.combBehavior}
onChange={(rating) => set("combBehavior", rating)}
lowLabel="läuft weg"
highLabel="sitzt ruhig"
/>
{hintFor("combBehavior")}
</>
)}
</Field>
<Field label="Schwarmstimmung" group>
{({ id, labelId }) => (
<RatingScale
id={id}
labelledBy={labelId}
value={values.swarmMood}
onChange={(rating) => set("swarmMood", rating)}
lowLabel="keine"
highLabel="stark"
/>
<>
<RatingScale
id={id}
labelledBy={labelId}
value={values.swarmMood}
onChange={(rating) => set("swarmMood", rating)}
direction="lower-is-better"
lowLabel="ruhig"
highLabel="stark"
/>
{hintFor("swarmMood")}
</>
)}
</Field>
</FormSection>
@@ -272,25 +585,29 @@ export default function NewInspectionPage() {
<FormSection step={4} title="Futter">
<Field label="Futterstatus" group>
{({ id, labelId }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
options={FEED_STATUS_OPTIONS}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
<>
<ChoiceGroup
id={id}
labelledBy={labelId}
options={FEED_STATUS_OPTIONS}
value={values.feedStatus}
onChange={(feedStatus) => set("feedStatus", feedStatus)}
className="grid-cols-2 sm:grid-cols-4"
/>
{hintFor("feedStatus")}
</>
)}
</Field>
</FormSection>
<FormSection
step={5}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={7}
title="Wetter"
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:hidden">
{weatherNotice}
</p>
<Field label="Wetter">
{({ id }) => (
<Input
@@ -313,16 +630,28 @@ export default function NewInspectionPage() {
/>
)}
</Field>
</FormSection>
<FormSection
step={6}
title="Weitere Angaben"
collapsible
defaultOpen={false}
fieldCount={5}
>
<Field label="Honigraum">
{({ id }) => (
<Input
id={id}
className="h-11"
value={values.honeySuperStatus}
onChange={(event) =>
set("honeySuperStatus", event.target.value)
}
/>
<>
<Input
id={id}
className="h-11"
value={values.honeySuperStatus}
onChange={(event) =>
set("honeySuperStatus", event.target.value)
}
/>
{hintFor("honeySuperStatus")}
</>
)}
</Field>
<Field label="Varroa-Befund">
@@ -369,14 +698,21 @@ export default function NewInspectionPage() {
</Field>
</FormSection>
<PhotoCaptureField
step={7}
sessionId={values.photoSessionId}
date={values.date}
onChange={() => setDirty(true)}
/>
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -384,9 +720,9 @@ export default function NewInspectionPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending
{capture.isPending
? "Wird gespeichert…"
: "Durchsicht speichern"}
</Button>
@@ -397,3 +733,23 @@ export default function NewInspectionPage() {
</main>
);
}
export default function NewInspectionPage() {
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const colonyId = searchParams.get("colonyId") ?? "";
const round = searchParams.get("round");
// A new colony or round remounts the form: fresh entry, fresh idempotency key.
return (
<InspectionCaptureForm
key={`${colonyId}:${round ?? ""}`}
initialColonyId={colonyId}
roundApiaryId={round}
backTarget={backTarget}
/>
);
}
+60 -2
View File
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Card, CardContent } from "@/components/ui/card";
import { StickySubmit } from "@/components/form/sticky-submit";
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
import AppLayout from "./layout";
vi.mock("next/navigation", () => ({
@@ -27,8 +28,24 @@ vi.mock("@/lib/auth/auth-context", () => ({
}));
vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null }));
vi.mock("@/components/offline-sync-badge", () => ({ OfflineSyncBadge: () => null }));
vi.mock("@/components/offline/offline-band", () => ({
OfflineBand: ({
userId,
pageContentRef,
}: {
userId: string;
pageContentRef: { current: HTMLElement | null };
}) => (
<div data-testid="offline-band">
{userId}
<button type="button" onClick={() => pageContentRef.current?.focus()}>
Fokus auf den Seiteninhalt
</button>
</div>
),
}));
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
vi.mock("@/lib/photos/store", () => ({ purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0) }));
// A box with overflow auto/scroll/hidden is a scroll container. Any such
// ancestor of the page content captures `position: sticky` (StickySubmit),
@@ -42,6 +59,47 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
}
describe("AppLayout", () => {
it("shows the offline band for the signed-in user right below the header", () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
const band = screen.getByTestId("offline-band");
expect(band).toHaveTextContent("u1");
expect(band.previousElementSibling?.tagName).toBe("HEADER");
});
it("gives the band the page content wrapper as a stable focus target", () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
fireEvent.click(screen.getByRole("button", { name: "Fokus auf den Seiteninhalt" }));
const wrapper = screen.getByText("Seiteninhalt").closest("[tabindex='-1']");
expect(wrapper).not.toBeNull();
expect(wrapper).toHaveFocus();
expect(wrapper?.previousElementSibling).toBe(screen.getByTestId("offline-band"));
});
it("deletes photos of form drafts older than 3 days when the app starts", async () => {
vi.mocked(purgeStaleDraftPhotos).mockClear();
const before = Date.now();
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
await waitFor(() => expect(purgeStaleDraftPhotos).toHaveBeenCalledTimes(1));
const [now] = vi.mocked(purgeStaleDraftPhotos).mock.calls[0];
expect(now).toBeGreaterThanOrEqual(before);
});
it("does not wrap the page content in its own scroll container", () => {
render(
<AppLayout>
+21 -7
View File
@@ -1,12 +1,13 @@
"use client";
import { ReactNode, useEffect } from "react";
import { ReactNode, useEffect, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Hexagon } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
import { NotificationBell } from "@/components/notification-bell";
import { OfflineSyncBadge } from "@/components/offline-sync-badge";
import { OfflineBand } from "@/components/offline/offline-band";
import { UserAvatar } from "@/components/user-avatar";
import {
DropdownMenu,
@@ -19,12 +20,14 @@ import {
} from "@/components/ui/dropdown-menu";
import { Sidebar } from "./sidebar";
import { BottomNav } from "./bottom-nav";
import { QuickAddMenu } from "./quick-add-menu";
import { QuickAddSheet } from "./quick-add-sheet";
import { PageTransition } from "./page-transition";
export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const { user, isLoading, logout } = useAuth();
// Focus lands here when the offline queue sheet closes and nothing else is left.
const pageContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isLoading && !user) {
@@ -32,6 +35,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
}
}, [isLoading, user, router]);
// Photos of form drafts nobody can restore any more (older than 3 days)
// leave the device. Housekeeping only: if it fails, the next start tries again.
useEffect(() => {
void purgeStaleDraftPhotos(Date.now()).catch(() => undefined);
}, []);
if (isLoading || !user) {
return null;
}
@@ -57,7 +66,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
ApiCore
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
<OfflineSyncBadge />
<QuickAddSheet user={user} variant="header" />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full">
@@ -85,12 +94,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</DropdownMenu>
</div>
</header>
<div className="flex-1 pb-(--bottom-nav-height) sm:pb-(--safe-area-bottom)">
<OfflineBand userId={user.id} pageContentRef={pageContentRef} />
<div
ref={pageContentRef}
tabIndex={-1}
className="flex-1 pb-(--bottom-nav-height) outline-none sm:pb-(--safe-area-bottom)"
>
<PageTransition>{children}</PageTransition>
</div>
</div>
<BottomNav />
<QuickAddMenu />
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
</div>
);
}
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MoreSheet } from "./more-sheet";
vi.mock("next/navigation", () => ({
usePathname: () => "/dashboard",
}));
describe("MoreSheet", () => {
it('„Mehr" opens a sheet listing Honigchargen with 56 px rows', async () => {
render(<MoreSheet user={{ permissions: [] }} />);
await userEvent.click(screen.getByRole("button", { name: "Mehr" }));
const link = await screen.findByRole("link", { name: "Honigchargen" });
expect(link).toHaveAttribute("href", "/honey-batches");
expect(link).toHaveClass("min-h-14");
});
it("lists only what the user may see", async () => {
render(<MoreSheet user={{ permissions: [] }} />);
await userEvent.click(screen.getByRole("button", { name: "Mehr" }));
await screen.findByRole("link", { name: "Honigchargen" });
expect(screen.queryByRole("link", { name: "Audit-Log" })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu } from "lucide-react";
import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import type { AuthUser } from "@/lib/auth/auth-context";
import { BOTTOM_NAV_HREFS, isNavActive, visibleNavGroups } from "./nav-config";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
/**
* The fifth bottom-nav entry: opens every area the user may see, grouped like
* the desktop sidebar. Rows are 56px high for gloved or hurried thumbs.
*/
export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const groups = visibleNavGroups(user);
// „Mehr" is the current place when the open page is not one of the four
// favourites next to it.
const isMoreActive =
!BOTTOM_NAV_HREFS.some((href) => isNavActive(pathname, href)) &&
groups.some((group) =>
group.items.some((item) => isNavActive(pathname, item.href)),
);
return (
<NavSheet
open={open}
onOpenChange={setOpen}
title="Mehr"
trigger={
<SheetTrigger
className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
isMoreActive ? "text-primary" : "text-muted-foreground",
)}
>
<Menu className="size-5" aria-hidden="true" />
Mehr
</SheetTrigger>
}
>
<nav aria-label="Alle Bereiche" className="flex flex-col gap-4 px-2 pb-4">
{groups.map((group, index) => (
<div key={group.label ?? `group-${index}`} className="flex flex-col">
{group.label ? (
<p className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground/80 uppercase">
{group.label}
</p>
) : null}
{group.items.map((item) => {
const Icon = item.icon;
const isActive = isNavActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
onClick={() => setOpen(false)}
className={cn(
NAV_SHEET_ROW_CLASS,
isActive
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-accent/70",
)}
>
<Icon className="size-6 shrink-0" aria-hidden="true" />
{item.label}
</Link>
);
})}
</div>
))}
</nav>
</NavSheet>
);
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
ADMIN_GROUP,
BOTTOM_NAV_HREFS,
NAV_GROUPS,
visibleNavGroups,
} from "./nav-config";
const ALL_ITEMS = [...NAV_GROUPS, ADMIN_GROUP].flatMap((group) => group.items);
const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) =>
item.permission === undefined ? [] : [item.permission].flat(),
);
function hrefs(permissions: string[]): string[] {
return visibleNavGroups({ permissions })
.flatMap((group) => group.items)
.map((item) => item.href);
}
describe("nav-config", () => {
it('every sidebar area is reachable on a phone via bottom nav or „Mehr"', () => {
const sidebar = hrefs(ALL_PERMISSIONS);
// „Mehr" lists every visible group, the bottom nav the four favourites.
const phone = new Set([...BOTTOM_NAV_HREFS, ...sidebar]);
expect(sidebar.length).toBe(ALL_ITEMS.length);
expect(sidebar.every((href) => phone.has(href))).toBe(true);
expect(sidebar).toContain("/honey-batches");
});
it("keeps every bottom nav entry in the nav config", () => {
const known = ALL_ITEMS.map((item) => item.href);
expect(BOTTOM_NAV_HREFS.every((href) => known.includes(href))).toBe(true);
});
it("admin entries need their permission", () => {
expect(hrefs([])).not.toContain("/settings/audit-log");
expect(hrefs([])).not.toContain("/settings/backup");
expect(visibleNavGroups({ permissions: [] }).map((g) => g.label)).not.toContain(
"Verwaltung",
);
const admin = visibleNavGroups({ permissions: ["audit:read"] }).find(
(group) => group.label === "Verwaltung",
);
expect(admin?.items.map((item) => item.href)).toEqual(["/settings/audit-log"]);
});
it("hides items the user may not see", () => {
expect(hrefs([])).not.toContain("/winter");
expect(hrefs(["winter-checks:read"])).toContain("/winter");
});
});
@@ -0,0 +1,193 @@
import {
BarChart3,
Boxes,
CalendarDays,
Crown,
Database,
Droplet,
Factory,
FlaskConical,
History,
Layers,
LayoutDashboard,
ListTodo,
type LucideIcon,
MapPin,
Package,
Scissors,
Search,
ShoppingBag,
Snowflake,
Users,
Warehouse,
Wrench,
Zap,
} from "lucide-react";
import type { AuthUser } from "@/lib/auth/auth-context";
/**
* The single source of truth for every area of the app: the desktop sidebar,
* the phone bottom nav and its „Mehr" sheet all read from here, so an area
* can never be reachable on one screen size and missing on another.
*/
export interface NavItem {
label: string;
/** Short name for the cramped bottom nav. */
shortLabel?: string;
href: string;
icon: LucideIcon;
// Any one of these grants visibility - used for hub pages that fold
// together several previously-separate settings pages, each gated by its
// own permission.
permission?: string | string[];
}
export interface NavGroup {
label: string | null;
items: NavItem[];
}
export const NAV_GROUPS: NavGroup[] = [
{
label: null,
items: [
{
label: "Dashboard",
shortLabel: "Start",
href: "/dashboard",
icon: LayoutDashboard,
},
{ label: "Suche", href: "/search", icon: Search },
],
},
{
label: "Betrieb",
items: [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
{
label: "Überwinterung",
href: "/winter",
icon: Snowflake,
permission: "winter-checks:read",
},
],
},
{
label: "Arbeit",
items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
],
},
{
label: "Ernte & Lager",
items: [
{ label: "Ernten", href: "/harvests", icon: Scissors },
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package },
{
label: "Rohstoffe",
href: "/raw-materials",
icon: Boxes,
permission: "raw-material:read",
},
{
label: "Herstellungschargen",
href: "/production-batches",
icon: Factory,
permission: "production-batch:read",
},
{ label: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench },
],
},
{
label: "Auswertung",
items: [{ label: "Statistik", href: "/statistics", icon: BarChart3 }],
},
];
export const ADMIN_GROUP: NavGroup = {
label: "Verwaltung",
items: [
{
label: "Benutzer & Rollen",
href: "/settings/users-roles",
icon: Users,
permission: ["users:read", "roles:read"],
},
{
label: "Automatisierung",
href: "/settings/automation",
icon: Zap,
permission: [
"webhooks:read",
"automation:read",
"task-templates:read",
"label-templates:read",
"site-settings:read",
"calendar:configure",
"api-keys:manage",
],
},
{
label: "Backup",
href: "/settings/backup",
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"],
},
{
label: "Audit-Log",
href: "/settings/audit-log",
icon: History,
permission: "audit:read",
},
],
};
/** The four favourites on the phone; everything else sits behind „Mehr". */
export const BOTTOM_NAV_HREFS = [
"/dashboard",
"/colonies",
"/tasks",
"/search",
] as const;
function isVisible(
user: Pick<AuthUser, "permissions">,
permission: NavItem["permission"],
): boolean {
if (!permission) {
return true;
}
const required = Array.isArray(permission) ? permission : [permission];
return required.some((key) => user.permissions.includes(key));
}
/** All groups the user may see, without empty ones; admin group last. */
export function visibleNavGroups(user: Pick<AuthUser, "permissions">): NavGroup[] {
return [...NAV_GROUPS, ADMIN_GROUP]
.map((group) => ({
...group,
items: group.items.filter((item) => isVisible(user, item.permission)),
}))
.filter((group) => group.items.length > 0);
}
/** The bottom nav entries, resolved from the config in their fixed order. */
export function bottomNavItems(): NavItem[] {
const all = NAV_GROUPS.flatMap((group) => group.items);
return BOTTOM_NAV_HREFS.flatMap((href) => all.filter((item) => item.href === href));
}
/**
* Whether a nav entry is the current area. Matches on a path boundary, so
* `/queens` does not light up for `/queens-archive`.
*/
export function isNavActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
@@ -63,3 +63,50 @@ describe("NewProductionBatchPage – consumed raw materials", () => {
expect(middle).not.toBeInTheDocument();
});
});
describe("NewProductionBatchPage – leaving", () => {
beforeEach(() => {
vi.mocked(fetchRawMaterialLots).mockResolvedValue([LOT] as never);
vi.mocked(fetchProducts).mockResolvedValue([]);
});
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewProductionBatchPage />
</QueryClientProvider>,
);
}
it("lets an untouched form go back without asking", () => {
renderPage();
expect(screen.getByRole("link", { name: /Zu den Herstellungschargen/ })).toBeInTheDocument();
expect(screen.queryByText("Formular verlassen?")).not.toBeInTheDocument();
});
it("asks before leaving once a raw material row was added", async () => {
renderPage();
const addRow = await screen.findByRole("button", { name: "+ Rohstoff hinzufügen" });
await vi.waitFor(() => expect(addRow).toBeEnabled());
await userEvent.click(addRow);
await userEvent.click(screen.getByRole("link", { name: /Zu den Herstellungschargen/ }));
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
});
it("still asks after removing one of two rows", async () => {
renderPage();
const addRow = await screen.findByRole("button", { name: "+ Rohstoff hinzufügen" });
await vi.waitFor(() => expect(addRow).toBeEnabled());
await userEvent.click(addRow);
await userEvent.click(addRow);
await userEvent.click(screen.getByRole("button", { name: "Rohstoff 1 entfernen" }));
await userEvent.click(screen.getByRole("link", { name: /Zu den Herstellungschargen/ }));
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
});
});
@@ -12,6 +12,7 @@ import {
RAW_MATERIAL_KIND_LABELS,
formatGrams,
} from "@/lib/raw-materials/labels";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -34,7 +35,8 @@ export default function NewProductionBatchPage() {
const queryClient = useQueryClient();
const [productId, setProductId] = useState("");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(todayLocalIsoDate());
const [initialDate] = useState(todayLocalIsoDate);
const [date, setDate] = useState(initialDate);
const [quantity, setQuantity] = useState("");
const [bestBeforeDate, setBestBeforeDate] = useState("");
const [notes, setNotes] = useState("");
@@ -60,6 +62,19 @@ export default function NewProductionBatchPage() {
const removeInput = (key: string) =>
setInputs((current) => current.filter((row) => row.key !== key));
const [error, setError] = useState<string | null>(null);
// No autosaved draft here, so leaving asks whenever the entry differs from
// the untouched form. Derived from the values: custom selects and the row
// buttons do not fire a form change event. `touched` only supplements.
const [touched, setTouched] = useState(false);
const dirty =
touched ||
productId !== "" ||
lotNumber !== "" ||
date !== initialDate ||
quantity !== "" ||
bestBeforeDate !== "" ||
notes !== "" ||
inputs.length > 0;
const { data: products } = useQuery({
queryKey: ["products", { status: "ACTIVE" }],
@@ -111,6 +126,11 @@ export default function NewProductionBatchPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/production-batches"
label="Zu den Herstellungschargen"
dirty={dirty}
/>
<PageHeader
icon={Factory}
title="Neue Herstellungscharge"
@@ -120,6 +140,7 @@ export default function NewProductionBatchPage() {
<CardContent>
<form
className="flex max-w-xl flex-col gap-4"
onChange={() => setTouched(true)}
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -85,6 +86,12 @@ export default function NewProductPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/products"
label="Zu den Produkten"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={ShoppingBag}
title="Neues Produkt"
@@ -10,6 +10,7 @@ import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { RecordSelect } from "@/components/form/record-select";
@@ -113,6 +114,12 @@ export default function NewQueenPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/queens"
label="Zu den Königinnen"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Crown}
title="Neue Königin"
@@ -0,0 +1,63 @@
import {
Bug,
ClipboardCheck,
ListTodo,
type LucideIcon,
Scissors,
Sprout,
Syringe,
Utensils,
} from "lucide-react";
export interface QuickAction {
label: string;
href: string;
icon: LucideIcon;
/** Creating needs this permission; actions without it are not offered. */
permission: string;
}
export const QUICK_ACTIONS: readonly QuickAction[] = [
{
label: "Durchsicht",
href: "/inspections/new",
icon: ClipboardCheck,
permission: "inspections:create",
},
{
label: "Varroa-Messung",
href: "/varroa/new",
icon: Bug,
permission: "varroa:create",
},
{
label: "Schwarmkontrolle",
href: "/swarm-control/new",
icon: Sprout,
permission: "swarm-control:create",
},
{
label: "Behandlung",
href: "/treatments/new",
icon: Syringe,
permission: "treatments:create",
},
{
label: "Fütterung",
href: "/feedings/new",
icon: Utensils,
permission: "feedings:create",
},
{
label: "Aufgabe",
href: "/tasks/new",
icon: ListTodo,
permission: "tasks:create",
},
{
label: "Ernte",
href: "/harvests/new",
icon: Scissors,
permission: "harvests:create",
},
];
@@ -1,38 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QuickAddMenu } from "./quick-add-menu";
let mockPathname = "/dashboard";
vi.mock("next/navigation", () => ({
usePathname: () => mockPathname,
}));
describe("QuickAddMenu", () => {
beforeEach(() => {
mockPathname = "/dashboard";
});
it.each(["/dashboard", "/colonies", "/colonies/abc", "/news", "/tasks/renewal"])(
"offers the quick-add button on %s",
(pathname) => {
mockPathname = pathname;
render(<QuickAddMenu />);
expect(screen.getByRole("button", { name: "Neu" })).toBeInTheDocument();
},
);
it.each([
"/inspections/new",
"/settings/label-templates/new",
"/colonies/abc/edit",
"/colonies/new/",
])("hides the quick-add button on the form route %s so it cannot cover the save bar", (pathname) => {
mockPathname = pathname;
render(<QuickAddMenu />);
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
});
});
@@ -1,62 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const QUICK_ACTIONS = [
{ label: "Durchsicht", href: "/inspections/new" },
{ label: "Varroa-Messung", href: "/varroa/new" },
{ label: "Schwarmkontrolle", href: "/swarm-control/new" },
{ label: "Behandlung", href: "/treatments/new" },
{ label: "Fütterung", href: "/feedings/new" },
{ label: "Aufgabe", href: "/tasks/new" },
{ label: "Ernte", href: "/harvests/new" },
];
// Create and edit forms end in a save bar (StickySubmit) right where this
// button floats. Deciding by route rather than letting StickySubmit register
// itself in a context is known on the first render (no flash, no layout
// shift), needs no client state in StickySubmit and also covers form routes
// without a sticky bar. Mid-form, jumping to another new form is no quick
// action anyway.
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
export function QuickAddMenu() {
const pathname = usePathname();
if (FORM_ROUTE.test(pathname)) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon-lg"
// size-12: 48px touch target for gloved hands (icon-lg is 36px).
className="fixed right-4 bottom-[calc(var(--bottom-nav-height)+--spacing(4))] z-40 size-12 rounded-full shadow-lg sm:hidden"
aria-label="Neu"
/>
}
>
<Plus className="size-6" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top">
{QUICK_ACTIONS.map((action) => (
<DropdownMenuItem key={action.href} render={<Link href={action.href} />}>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QUICK_ACTIONS } from "./quick-actions";
import { QuickAddSheet } from "./quick-add-sheet";
let mockPathname = "/dashboard";
vi.mock("next/navigation", () => ({
usePathname: () => mockPathname,
}));
const ALL = QUICK_ACTIONS.map((action) => action.permission);
describe("QuickAddSheet", () => {
beforeEach(() => {
mockPathname = "/dashboard";
});
it("lists actions with icons as links", async () => {
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
const link = await screen.findByRole("link", { name: "Durchsicht" });
expect(link).toHaveAttribute("href", "/inspections/new");
expect(link).toHaveClass("min-h-14");
expect(link.querySelector("svg.size-6")).not.toBeNull();
expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length);
});
it("hides Behandlung without treatments:create", async () => {
render(
<QuickAddSheet
user={{ permissions: ALL.filter((p) => p !== "treatments:create") }}
variant="fab"
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
await screen.findByRole("link", { name: "Durchsicht" });
expect(screen.queryByRole("link", { name: "Behandlung" })).not.toBeInTheDocument();
});
it("renders nothing when no action is allowed", () => {
render(<QuickAddSheet user={{ permissions: [] }} variant="fab" />);
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
});
it.each([
"/inspections/new",
"/settings/label-templates/new",
"/colonies/abc/edit",
"/colonies/new/",
])("is not rendered on the form route %s", (pathname) => {
mockPathname = pathname;
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
});
it.each(["/dashboard", "/colonies/abc", "/news", "/tasks/renewal"])(
"offers the button on %s",
(pathname) => {
mockPathname = pathname;
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
expect(screen.getByRole("button", { name: "Neu" })).toBeInTheDocument();
},
);
it("offers a header button on desktop", () => {
render(<QuickAddSheet user={{ permissions: ALL }} variant="header" />);
const button = screen.getByRole("button", { name: /Neu/ });
expect(button).toHaveClass("sm:inline-flex");
expect(button).toHaveClass("hidden");
});
});
@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
import type { AuthUser } from "@/lib/auth/auth-context";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
import { QUICK_ACTIONS } from "./quick-actions";
// Create and edit forms end in a save bar (StickySubmit) right where the
// phone button floats. Deciding by route rather than letting StickySubmit
// register itself in a context is known on the first render (no flash, no
// layout shift), needs no client state in StickySubmit and also covers form
// routes without a sticky bar. Mid-form, jumping to another new form is no
// quick action anyway - and the header button hides with it for consistency.
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
interface QuickAddSheetProps {
user: Pick<AuthUser, "permissions">;
/** "fab" floats above the phone bottom nav, "header" sits in the desktop header. */
variant: "fab" | "header";
}
/** „+ Neu": the same icon sheet on the phone (FAB) and on the desktop (header). */
export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const actions = QUICK_ACTIONS.filter((action) =>
user.permissions.includes(action.permission),
);
if (FORM_ROUTE.test(pathname) || actions.length === 0) {
return null;
}
return (
<NavSheet
open={open}
onOpenChange={setOpen}
title="Neu erfassen"
className="sm:mx-auto sm:max-w-md"
trigger={
variant === "fab" ? (
<SheetTrigger
render={
<Button
size="icon-lg"
// size-12: 48px touch target for gloved hands (icon-lg is 36px).
className="fixed right-4 bottom-[calc(var(--bottom-nav-height)+--spacing(4))] z-40 size-12 rounded-full shadow-lg sm:hidden"
aria-label="Neu"
/>
}
>
<Plus className="size-6" aria-hidden="true" />
</SheetTrigger>
) : (
<SheetTrigger
render={
<Button className="hidden min-h-11 gap-1.5 sm:inline-flex" />
}
>
<Plus className="size-4" aria-hidden="true" />
Neu
</SheetTrigger>
)
}
>
<nav aria-label="Neu erfassen" className="flex flex-col px-2 pb-4">
{actions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.href}
href={action.href}
onClick={() => setOpen(false)}
className={`${NAV_SHEET_ROW_CLASS} text-foreground hover:bg-accent/70`}
>
<Icon
className="size-6 shrink-0 text-primary"
aria-hidden="true"
/>
{action.label}
</Link>
);
})}
</nav>
</NavSheet>
);
}
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewRawMaterialPage from "./page";
import { fetchApiaries } from "@/lib/apiaries/api";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
vi.mock("@/lib/raw-materials/api", () => ({ createRawMaterialLot: vi.fn() }));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewRawMaterialPage />
</QueryClientProvider>,
);
}
describe("NewRawMaterialPage – leaving", () => {
beforeEach(() => {
vi.mocked(fetchApiaries).mockResolvedValue([]);
});
it("lets an untouched form go back without asking", async () => {
renderPage();
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
expect(screen.queryByText("Formular verlassen?")).not.toBeInTheDocument();
});
it("asks before leaving after only a select value was changed", async () => {
renderPage();
await userEvent.click(screen.getByRole("combobox", { name: "Art" }));
const options = await screen.findAllByRole("option");
await userEvent.click(options[options.length - 1]);
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
});
it("asks before leaving after typing", async () => {
renderPage();
await userEvent.type(screen.getByLabelText("Menge (g)"), "100");
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ import { createRawMaterialLot } from "@/lib/raw-materials/api";
import { RAW_MATERIAL_KIND_LABELS } from "@/lib/raw-materials/labels";
import { RawMaterialKind } from "@/lib/raw-materials/types";
import { fetchApiaries } from "@/lib/apiaries/api";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -25,12 +26,25 @@ export default function NewRawMaterialPage() {
const queryClient = useQueryClient();
const [kind, setKind] = useState<RawMaterialKind>("BEESWAX");
const [lotNumber, setLotNumber] = useState("");
const [date, setDate] = useState(todayLocalIsoDate);
const [initialDate] = useState(todayLocalIsoDate);
const [date, setDate] = useState(initialDate);
const [quantity, setQuantity] = useState("");
// "" = "Kein Stand".
const [apiaryId, setApiaryId] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
// No autosaved draft here, so leaving asks whenever the entry differs from
// the untouched form. Derived from the values: custom selects do not fire
// a form change event. `touched` only supplements.
const [touched, setTouched] = useState(false);
const dirty =
touched ||
kind !== "BEESWAX" ||
lotNumber !== "" ||
date !== initialDate ||
quantity !== "" ||
apiaryId !== "" ||
notes !== "";
const { data: apiaries } = useQuery({
queryKey: ["apiaries"],
@@ -69,6 +83,11 @@ export default function NewRawMaterialPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/raw-materials"
label="Zu den Rohstoffen"
dirty={dirty}
/>
<PageHeader
icon={Boxes}
title="Neuer Rohstoff"
@@ -78,6 +97,7 @@ export default function NewRawMaterialPage() {
<CardContent>
<form
className="flex max-w-xl flex-col gap-4"
onChange={() => setTouched(true)}
onSubmit={handleSubmit}
>
<div className="flex flex-col gap-2">
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiKeysTab } from "./api-keys-tab";
import { createApiKey, fetchApiKeys } from "@/lib/api-keys/api";
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/api-keys/api", () => ({
fetchApiKeys: vi.fn(),
createApiKey: vi.fn(),
revokeApiKey: vi.fn(),
updateApiKeyAllowedIps: vi.fn(),
}));
const HINT = /Dieser Schlüssel darf lesen und buchen/;
function makeKey(id: string, scopes: ApiKeyScope[]): ApiKey {
return {
id,
name: `Schlüssel ${id}`,
prefix: `ack_${id}`,
scopes,
createdByUserId: null,
createdAt: "2026-10-01T00:00:00.000Z",
lastUsedAt: null,
lastUsedIp: null,
allowedIps: [],
expiresAt: null,
revokedAt: null,
};
}
function renderTab() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<ApiKeysTab />
</QueryClientProvider>,
);
}
describe("ApiKeysTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows the separation hint only for a key with both scopes", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("both", ["shop-sync:read", "shop-sync:write"]),
makeKey("read", ["shop-sync:read"]),
]);
renderTab();
const hints = await screen.findAllByRole("status");
expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent(HINT);
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
});
it("shows no hint when every key has one scope", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([makeKey("read", ["shop-sync:read"])]);
renderTab();
await screen.findByText("Schlüssel read");
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
});
it("creates a read key by default", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:read"]), token: "t" });
renderTab();
await userEvent.click(await screen.findByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:read"]);
});
it('creates a write key when "Buchen" is chosen', async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:write"]), token: "t" });
renderTab();
await userEvent.click(await screen.findByRole("radio", { name: "Buchen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
});
});
@@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, KeyRound, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import { API_KEY_SCOPE_LABELS, ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import { API_KEY_SCOPE_LABELS, hasCombinedShopScopes } from "@/lib/api-keys/scopes";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,7 +15,13 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
// Reading and booking are separate keys, so a leaked key can only do one.
const SCOPE_CHOICE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Lesen",
"shop-sync:write": "Buchen",
};
const SCOPE_OPTIONS = choiceOptions(SCOPE_CHOICE_LABELS);
const SCOPE_LABEL_ID = "api-key-scope-label";
function formatDate(value: string | null): string {
return value
@@ -38,6 +46,7 @@ export function ApiKeysTab() {
const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState("");
const [allowedIps, setAllowedIps] = useState("");
const [scope, setScope] = useState<ApiKeyScope>("shop-sync:read");
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
const [copied, setCopied] = useState(false);
@@ -75,7 +84,7 @@ export function ApiKeysTab() {
const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({
name: name.trim(),
scopes: DEFAULT_SCOPES,
scopes: [scope],
expiresInDays: days,
allowedIps: parseAllowlist(allowedIps),
});
@@ -139,6 +148,16 @@ export function ApiKeysTab() {
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex w-56 flex-col gap-1.5">
<Label id={SCOPE_LABEL_ID}>Berechtigung</Label>
<ChoiceGroup
options={SCOPE_OPTIONS}
value={scope}
onChange={setScope}
labelledBy={SCOPE_LABEL_ID}
className="grid-cols-2"
/>
</div>
<div className="flex w-44 flex-col gap-1.5">
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
<Input
@@ -170,7 +189,7 @@ export function ApiKeysTab() {
</Button>
</form>
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}. IP-Adressen mit Komma trennen; leer
Berechtigung: {API_KEY_SCOPE_LABELS[scope]}. Lesen und Buchen bekommen je einen eigenen Schlüssel. IP-Adressen mit Komma trennen; leer
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ –
die zuletzt benutzte Adresse lässt sich übernehmen.
</p>
@@ -200,6 +219,20 @@ export function ApiKeysTab() {
<Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge>
</span>
<span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</span>
<span className="flex flex-wrap gap-1.5">
{apiKey.scopes.map((keyScope) => (
<Badge key={keyScope} variant="secondary">
{API_KEY_SCOPE_LABELS[keyScope]}
</Badge>
))}
</span>
{state.active && hasCombinedShopScopes(apiKey) ? (
<p role="status" className="text-xs text-muted-foreground">
Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte
Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in
Wabenhain eintragen, dann diesen widerrufen.
</p>
) : null}
<span className="text-xs text-muted-foreground">
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
{formatDate(apiKey.lastUsedAt)}
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SocialMediaTab } from "./social-media-tab";
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
import type { SiteSettings } from "@/lib/site-settings/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/site-settings/api", () => ({
fetchSiteSettings: vi.fn(),
updateSiteSettings: vi.fn(),
}));
const settings: SiteSettings = {
id: "singleton",
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
websiteUrl: null,
holderName: null,
holderAddress: null,
originCountry: "Deutschland",
updatedAt: "2026-10-13T00:00:00.000Z",
};
function renderTab() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<SocialMediaTab />
</QueryClientProvider>,
);
}
describe("SocialMediaTab origin country", () => {
beforeEach(() => {
vi.mocked(fetchSiteSettings).mockReset();
vi.mocked(updateSiteSettings).mockReset();
vi.mocked(fetchSiteSettings).mockResolvedValue(settings);
vi.mocked(updateSiteSettings).mockResolvedValue(settings);
});
it("shows the saved country with its hint", async () => {
renderTab();
expect(await screen.findByLabelText(/^Herkunftsland/)).toHaveValue("Deutschland");
expect(screen.getByText(/Erscheint auf der Herkunftsseite/)).toBeInTheDocument();
});
it("saves a changed country trimmed", async () => {
renderTab();
const input = await screen.findByLabelText(/^Herkunftsland/);
await userEvent.clear(input);
await userEvent.type(input, " Österreich ");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({
originCountry: "Österreich",
});
});
it("does not save an emptied country (the server cannot hold none)", async () => {
renderTab();
await userEvent.clear(await screen.findByLabelText(/^Herkunftsland/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(updateSiteSettings).not.toHaveBeenCalled();
expect(await screen.findByText("Bitte ein Land angeben.")).toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
// Mounted only once `settings` has arrived, so its useState initial values
// already reflect the fetched data - no synchronization effect needed.
@@ -20,6 +21,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
const [holderName, setHolderName] = useState(settings.holderName ?? "");
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
const [originCountry, setOriginCountry] = useState(settings.originCountry);
const [originCountryError, setOriginCountryError] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: updateSiteSettings,
@@ -32,6 +35,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// NOT NULL in the database: it can be changed, not emptied.
if (originCountry.trim().length < 2) {
setOriginCountryError("Bitte ein Land angeben.");
return;
}
mutation.mutate({
youtubeUrl,
instagramUrl,
@@ -39,6 +47,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
websiteUrl,
holderName,
holderAddress,
originCountry: originCountry.trim(),
});
}
@@ -90,6 +99,26 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
onChange={(event) => setWebsiteUrl(event.target.value)}
/>
</div>
<Field
label="Herkunftsland"
required
hint="Erscheint auf der Herkunftsseite, z. B. „Deutschland“."
error={originCountryError}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
maxLength={80}
aria-describedby={describedBy}
aria-invalid={invalid}
value={originCountry}
onChange={(event) => {
setOriginCountry(event.target.value);
setOriginCountryError(null);
}}
/>
)}
</Field>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Bienenhalter (für das Bestandsbuch)</legend>
<p className="text-xs text-muted-foreground">
@@ -2,6 +2,7 @@
import { Tag } from "lucide-react";
import { createLabelTemplate } from "@/lib/label-templates/api";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer";
@@ -12,6 +13,11 @@ export default function NewLabelTemplatePage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/settings/automation"
label="Zur Automatisierung"
dirty={false}
/>
<PageHeader
icon={Tag}
title="Neue Etikettenvorlage"
+4 -158
View File
@@ -2,151 +2,14 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BarChart3,
CalendarDays,
Crown,
Database,
Droplet,
Factory,
Boxes,
FileClock,
FlaskConical,
Hexagon,
History,
LayoutDashboard,
Layers,
ListTodo,
type LucideIcon,
MapPin,
Package,
Scissors,
Search,
Snowflake,
ShoppingBag,
Users,
Warehouse,
Wrench,
Zap,
} from "lucide-react";
import { FileClock, Hexagon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AuthUser } from "@/lib/auth/auth-context";
interface NavItem {
label: string;
href: string;
icon: LucideIcon;
// Any one of these grants visibility - used for hub pages that fold
// together several previously-separate settings pages, each gated by its
// own permission.
permission?: string | string[];
}
interface NavGroup {
label: string | null;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
label: null,
items: [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Suche", href: "/search", icon: Search },
],
},
{
label: "Betrieb",
items: [
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
{ label: "Völker", href: "/colonies", icon: Layers },
{ label: "Königinnen", href: "/queens", icon: Crown },
{
label: "Überwinterung",
href: "/winter",
icon: Snowflake,
permission: "winter-checks:read",
},
],
},
{
label: "Arbeit",
items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
],
},
{
label: "Ernte & Lager",
items: [
{ label: "Ernten", href: "/harvests", icon: Scissors },
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
{ label: "Abfüllungen", href: "/bottling", icon: Package },
{
label: "Rohstoffe",
href: "/raw-materials",
icon: Boxes,
permission: "raw-material:read",
},
{
label: "Herstellungschargen",
href: "/production-batches",
icon: Factory,
permission: "production-batch:read",
},
{ label: "Produkte", href: "/products", icon: ShoppingBag },
{ label: "Lager", href: "/storage", icon: Warehouse },
{ label: "Inventar", href: "/equipment", icon: Wrench },
],
},
{
label: "Auswertung",
items: [{ label: "Statistik", href: "/statistics", icon: BarChart3 }],
},
];
const ADMIN_GROUP: NavGroup = {
label: "Verwaltung",
items: [
{
label: "Benutzer & Rollen",
href: "/settings/users-roles",
icon: Users,
permission: ["users:read", "roles:read"],
},
{
label: "Automatisierung",
href: "/settings/automation",
icon: Zap,
permission: [
"webhooks:read",
"automation:read",
"task-templates:read",
"label-templates:read",
"site-settings:read",
"calendar:configure",
"api-keys:manage",
],
},
{
label: "Backup",
href: "/settings/backup",
icon: Database,
permission: ["backup:export", "backup:import", "import:execute"],
},
{
label: "Audit-Log",
href: "/settings/audit-log",
icon: History,
permission: "audit:read",
},
],
};
import { isNavActive, type NavItem, visibleNavGroups } from "./nav-config";
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
const isActive = isNavActive(pathname, item.href);
return (
<Link
href={item.href}
@@ -169,26 +32,9 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
);
}
function hasNavPermission(
user: AuthUser,
permission: NavItem["permission"],
): boolean {
if (!permission) {
return true;
}
const required = Array.isArray(permission) ? permission : [permission];
return required.some((key) => user.permissions.includes(key));
}
export function Sidebar({ user }: { user: AuthUser }) {
const pathname = usePathname();
const visibleAdminItems = ADMIN_GROUP.items.filter((item) =>
hasNavPermission(user, item.permission),
);
const groups =
visibleAdminItems.length > 0
? [...NAV_GROUPS, { ...ADMIN_GROUP, items: visibleAdminItems }]
: NAV_GROUPS;
const groups = visibleNavGroups(user);
return (
// The page scrolls on the window; the sidebar stays put beside it and
@@ -2,7 +2,7 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Sprout } from "lucide-react";
import { createSwarmControlCheck } from "@/lib/swarm-control/api";
@@ -10,14 +10,14 @@ import {
SWARM_ACTION_LABELS,
SWARM_MOOD_LABELS,
} from "@/lib/swarm-control/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueSwarmControlCheck } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -50,6 +50,10 @@ function validate(values: SwarmControlFormValues): FieldErrors {
export default function NewSwarmControlCheckPage() {
const router = useRouter();
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const queryClient = useQueryClient();
const [values, setValues] = useState<SwarmControlFormValues>(() =>
@@ -57,7 +61,6 @@ export default function NewSwarmControlCheckPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("swarm-control-new", values, dirty);
function set<K extends keyof SwarmControlFormValues>(
@@ -71,43 +74,30 @@ export default function NewSwarmControlCheckPage() {
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createSwarmControlCheck>[0]) =>
createSwarmControlCheck(input, { idempotencyKey }),
onSuccess: (check) => {
const capture = useCaptureMutation({
create: createSwarmControlCheck,
queue: queueSwarmControlCheck,
failureMessage: "Schwarmkontrolle konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${input.colonyId}`);
return;
}
const check = outcome.result;
void queryClient.invalidateQueries({
queryKey: ["colonies", check.colonyId, "swarm-control-checks"],
});
toast.success("Schwarmkontrolle gespeichert");
router.push(`/colonies/${check.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Schwarmkontrolle konnte nicht gespeichert werden.",
),
);
return;
}
void queueSwarmControlCheck(variables, idempotencyKey).then(() => {
draft.clear();
toast.info(
"Kein Netzwerk – Schwarmkontrolle wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -117,7 +107,7 @@ export default function NewSwarmControlCheckPage() {
?.focus();
return;
}
mutation.mutate(toSwarmControlInput(values));
capture.submit(toSwarmControlInput(values));
}
function restoreDraft() {
@@ -130,6 +120,12 @@ export default function NewSwarmControlCheckPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Sprout}
title="Neue Schwarmkontrolle"
@@ -270,12 +266,12 @@ export default function NewSwarmControlCheckPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -283,9 +279,9 @@ export default function NewSwarmControlCheckPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending
{capture.isPending
? "Wird gespeichert…"
: "Kontrolle speichern"}
</Button>
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { RecordSelect } from "@/components/form/record-select";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -108,6 +109,12 @@ export default function NewTaskPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href="/tasks"
label="Zu den Aufgaben"
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={ListTodo}
title="Neue Aufgabe"
@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, onTestFinished, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
import NewTreatmentPage from "./page";
import { createTreatment } from "@/lib/treatments/api";
import { createTreatment, fetchTreatmentPrefill } from "@/lib/treatments/api";
import { fetchColonies } from "@/lib/colonies/api";
const push = vi.fn();
@@ -12,9 +12,22 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() }));
vi.mock("@/lib/treatments/api", () => ({
createTreatment: vi.fn(),
fetchTreatmentPrefill: vi.fn(),
}));
vi.mock("@/lib/offline/db", () => ({ queueTreatment: vi.fn() }));
/** jsdom reports online; a test can take the device offline until it ends. */
function goOffline() {
Object.defineProperty(navigator, "onLine", { configurable: true, get: () => false });
onTestFinished(() => {
Reflect.deleteProperty(navigator, "onLine");
// TanStack's onlineManager is global and has seen the "offline" event.
onlineManager.setOnline(true);
});
}
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -34,8 +47,11 @@ describe("NewTreatmentPage", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createTreatment).mockReset();
vi.mocked(fetchTreatmentPrefill).mockReset().mockResolvedValue(null);
});
afterEach(() => vi.restoreAllMocks());
it("marks colony, date and product as required and keeps the Bestandsbuch visible", () => {
renderPage();
expect(screen.getAllByText("Pflicht")).toHaveLength(3);
@@ -91,4 +107,194 @@ describe("NewTreatmentPage", () => {
expect(screen.getByLabelText(/^Mittel/)).toHaveFocus();
expect(createTreatment).not.toHaveBeenCalled();
});
describe("prefill from the last treatment", () => {
const LAST = {
sourceTreatmentId: "t-0",
sourceDate: "2026-08-12T00:00:00.000Z",
product: "Oxuvar",
activeIngredient: "Oxalsäure",
withdrawalPeriodDays: 0,
supplier: "Imkereibedarf Muster",
purchaseProof: "Rechnung 4711",
prescribingVet: "Dr. Biene",
};
const WAIT = { timeout: 3000 };
it("after „Oxuvar“ fills Wirkstoff, Wartezeit, Lieferant, Beleg and Tierarzt with the hint", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Oxalsäure"),
WAIT,
);
expect(fetchTreatmentPrefill).toHaveBeenCalledWith("Oxuvar");
expect(screen.getByLabelText(/Wartezeit/)).toHaveValue(0);
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster");
expect(screen.getByLabelText(/Beleg/)).toHaveValue("Rechnung 4711");
expect(screen.getByLabelText(/Tierarzt/)).toHaveValue("Dr. Biene");
expect(screen.getAllByText("aus letzter Behandlung mit Oxuvar vom 12.08.2026")).toHaveLength(5);
expect(screen.getByLabelText(/Chargennummer/)).toHaveValue("");
});
it("never overwrites a typed Wirkstoff", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "Ameisensäure");
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Ameisensäure");
expect(screen.getAllByText("aus letzter Behandlung mit Oxuvar vom 12.08.2026")).toHaveLength(4);
});
it("prefilled values stay editable and a cleared one is sent as null", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
await userEvent.clear(screen.getByLabelText(/Lieferant/));
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "x");
expect(screen.getAllByText("aus letzter Behandlung mit Oxuvar vom 12.08.2026")).toHaveLength(3);
await userEvent.click(screen.getByRole("button", { name: "Behandlung speichern" }));
const [input] = vi.mocked(createTreatment).mock.calls[0];
expect(input).toMatchObject({
supplier: null,
activeIngredient: "Oxalsäurex",
withdrawalPeriodDays: 0,
purchaseProof: "Rechnung 4711",
batchNumber: null,
});
});
it("takes back only untouched prefilled fields", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
await userEvent.type(screen.getByLabelText(/Wirkstoff/), "!");
await userEvent.click(screen.getByRole("button", { name: "Vorbelegung zurücknehmen" }));
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("");
expect(screen.getByLabelText(/Tierarzt/)).toHaveValue("");
expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Oxalsäure!");
expect(screen.queryByText(/aus letzter Behandlung/)).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Vorbelegung zurücknehmen" }),
).not.toBeInTheDocument();
});
it("sends no prefill request while offline", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
goOffline();
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await act(() => vi.advanceTimersByTimeAsync(800));
expect(fetchTreatmentPrefill).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("drops the suggestions at once when the product changes", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(null);
await userEvent.type(screen.getByLabelText(/^Mittel/), " 60%");
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("");
expect(screen.queryByText(/aus letzter Behandlung/)).not.toBeInTheDocument();
});
it("sends nothing of product A when product B's request fails", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
vi.mocked(fetchTreatmentPrefill).mockRejectedValue(new Error("down"));
await userEvent.clear(screen.getByLabelText(/^Mittel/));
await userEvent.type(screen.getByLabelText(/^Mittel/), "Thymovar");
await waitFor(() => expect(fetchTreatmentPrefill).toHaveBeenCalledWith("Thymovar"), WAIT);
await userEvent.click(screen.getByRole("button", { name: "Behandlung speichern" }));
expect(vi.mocked(createTreatment).mock.calls[0][0]).toMatchObject({
product: "Thymovar",
activeIngredient: null,
withdrawalPeriodDays: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
});
});
it("keeps the suggestions of the same product when the connection drops", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
goOffline();
act(() => {
window.dispatchEvent(new Event("offline"));
});
await new Promise((resolve) => setTimeout(resolve, 700));
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster");
expect(screen.getAllByText("aus letzter Behandlung mit Oxuvar vom 12.08.2026")).toHaveLength(5);
});
it("does not refill a field the user cleared when the product changes", async () => {
vi.mocked(fetchTreatmentPrefill).mockResolvedValue(LAST);
vi.mocked(createTreatment).mockResolvedValue({ id: "t-1", colonyId: "colony-1" } as never);
renderPage();
await userEvent.type(screen.getByLabelText(/^Mittel/), "Oxuvar");
await waitFor(
() => expect(screen.getByLabelText(/Lieferant/)).toHaveValue("Imkereibedarf Muster"),
WAIT,
);
await userEvent.clear(screen.getByLabelText(/Lieferant/));
vi.mocked(fetchTreatmentPrefill).mockResolvedValue({ ...LAST, supplier: "Anderer", activeIngredient: "Thymol" });
await userEvent.type(screen.getByLabelText(/^Mittel/), "2");
await waitFor(() => expect(screen.getByLabelText(/Wirkstoff/)).toHaveValue("Thymol"), WAIT);
expect(screen.getByLabelText(/Lieferant/)).toHaveValue("");
await userEvent.click(screen.getByRole("button", { name: "Behandlung speichern" }));
expect(vi.mocked(createTreatment).mock.calls[0][0]).toMatchObject({ supplier: null });
});
});
});
@@ -2,18 +2,20 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Syringe } from "lucide-react";
import { createTreatment } from "@/lib/treatments/api";
import { ApiError } from "@/lib/auth/api-client";
import { queueTreatment } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { usePrefilledFields } from "@/lib/treatments/use-prefilled-fields";
import { PrefillNote, describedByWith } from "@/components/treatments/prefill-note";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -43,6 +45,10 @@ function validate(values: TreatmentFormValues): FieldErrors {
export default function NewTreatmentPage() {
const router = useRouter();
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const queryClient = useQueryClient();
@@ -51,57 +57,47 @@ export default function NewTreatmentPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("treatment-new", values, dirty);
const { prefilled, markEdited, undo: undoPrefill, reset: resetPrefill } =
usePrefilledFields(values, setValues);
function set<K extends keyof TreatmentFormValues>(
key: K,
value: TreatmentFormValues[K],
) {
setValues((current) => ({ ...current, [key]: value }));
setDirty(true);
markEdited(key);
if ((REQUIRED_KEYS as string[]).includes(key)) {
setFieldErrors((current) => ({ ...current, [key]: undefined }));
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createTreatment>[0]) =>
createTreatment(input, { idempotencyKey }),
onSuccess: (treatment) => {
const capture = useCaptureMutation({
create: createTreatment,
queue: queueTreatment,
failureMessage: "Behandlung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${input.colonyId}`);
return;
}
const treatment = outcome.result;
void queryClient.invalidateQueries({
queryKey: ["colonies", treatment.colonyId, "treatments"],
});
toast.success("Behandlung gespeichert");
router.push(`/colonies/${treatment.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Behandlung konnte nicht gespeichert werden.",
),
);
return;
}
void queueTreatment(variables, idempotencyKey).then(() => {
draft.clear();
toast.info(
"Kein Netzwerk – Behandlung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -111,19 +107,26 @@ export default function NewTreatmentPage() {
?.focus();
return;
}
mutation.mutate(toTreatmentInput(values));
capture.submit(toTreatmentInput(values));
}
function restoreDraft() {
const restored = draft.restore();
if (restored) {
setValues(restored);
resetPrefill();
setDirty(true);
}
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Syringe}
title="Neue Behandlung"
@@ -193,15 +196,18 @@ export default function NewTreatmentPage() {
hint="Steht auf der Packung, z. B. Ameisensäure."
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
className="h-11"
value={values.activeIngredient}
onChange={(event) =>
set("activeIngredient", event.target.value)
}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, describedBy, prefilled.activeIngredient)}
className="h-11"
value={values.activeIngredient}
onChange={(event) =>
set("activeIngredient", event.target.value)
}
/>
<PrefillNote id={id} source={prefilled.activeIngredient} />
</>
)}
</Field>
<Field label="Menge" hint="z. B. „50 ml“">
@@ -245,18 +251,21 @@ export default function NewTreatmentPage() {
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.withdrawalPeriodDays}
onChange={(event) =>
set("withdrawalPeriodDays", event.target.value)
}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, describedBy, prefilled.withdrawalPeriodDays)}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.withdrawalPeriodDays}
onChange={(event) =>
set("withdrawalPeriodDays", event.target.value)
}
/>
<PrefillNote id={id} source={prefilled.withdrawalPeriodDays} />
</>
)}
</Field>
</FormSection>
@@ -268,40 +277,58 @@ export default function NewTreatmentPage() {
>
<Field label="Lieferant (Name, Anschrift)">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={values.supplier}
onChange={(event) => set("supplier", event.target.value)}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.supplier)}
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={values.supplier}
onChange={(event) => set("supplier", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.supplier} />
</>
)}
</Field>
<Field label="Beleg für den Erwerb">
{({ id }) => (
<Input
id={id}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={values.purchaseProof}
onChange={(event) =>
set("purchaseProof", event.target.value)
}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.purchaseProof)}
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={values.purchaseProof}
onChange={(event) => set("purchaseProof", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.purchaseProof} />
</>
)}
</Field>
<Field label="Verschreibender Tierarzt (falls zutreffend)">
{({ id }) => (
<Input
id={id}
className="h-11"
value={values.prescribingVet}
onChange={(event) =>
set("prescribingVet", event.target.value)
}
/>
<>
<Input
id={id}
aria-describedby={describedByWith(id, undefined, prefilled.prescribingVet)}
className="h-11"
value={values.prescribingVet}
onChange={(event) => set("prescribingVet", event.target.value)}
/>
<PrefillNote id={id} source={prefilled.prescribingVet} />
</>
)}
</Field>
{Object.keys(prefilled).length > 0 ? (
<Button
type="button"
variant="outline"
className="h-11 self-start"
onClick={undoPrefill}
>
Vorbelegung zurücknehmen
</Button>
) : null}
</FormSection>
<FormSection
@@ -360,12 +387,12 @@ export default function NewTreatmentPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -373,9 +400,9 @@ export default function NewTreatmentPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending
{capture.isPending
? "Wird gespeichert…"
: "Behandlung speichern"}
</Button>
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import NewVarroaMeasurementPage from "./page";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { createVarroaMeasurement, fetchVarroaThreshold } from "@/lib/varroa/api";
import { fetchColonies } from "@/lib/colonies/api";
const push = vi.fn();
@@ -12,7 +12,11 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/varroa/api", () => ({ createVarroaMeasurement: vi.fn() }));
vi.mock("@/lib/varroa/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/varroa/api")>()),
createVarroaMeasurement: vi.fn(),
fetchVarroaThreshold: vi.fn(),
}));
vi.mock("@/lib/offline/db", () => ({ queueVarroaMeasurement: vi.fn() }));
function renderPage() {
@@ -34,6 +38,7 @@ describe("NewVarroaMeasurementPage", () => {
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
] as never);
vi.mocked(createVarroaMeasurement).mockReset();
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
});
it("marks colony and date as required and explains the daily mite fall", () => {
@@ -57,7 +62,7 @@ describe("NewVarroaMeasurementPage", () => {
screen.getByRole("radio", { name: "Puderzuckermethode" }),
);
await userEvent.type(screen.getByLabelText(/Milbenzahl/), "12");
await userEvent.type(screen.getByLabelText(/Zeitraum/), "5");
await userEvent.type(screen.getByLabelText(/^Zeitraum/), "5");
await userEvent.click(
screen.getByRole("button", { name: "Messung speichern" }),
);
@@ -74,6 +79,70 @@ describe("NewVarroaMeasurementPage", () => {
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
async function fillMiteCount(count: string) {
await userEvent.type(screen.getByLabelText(/Milbenzahl/), count);
}
async function submit() {
await userEvent.click(screen.getByRole("button", { name: "Messung speichern" }));
}
it("preselects no period chip", () => {
renderPage();
for (const name of ["1 Tag", "2 Tage", "3 Tage", "7 Tage", "anders"]) {
expect(screen.getByRole("radio", { name })).toHaveAttribute("aria-checked", "false");
}
});
it('„3 Tage" sends durationMinutes 4320', async () => {
vi.mocked(createVarroaMeasurement).mockResolvedValue({ id: "v", colonyId: "colony-1" } as never);
renderPage();
await fillMiteCount("30");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
await submit();
expect(vi.mocked(createVarroaMeasurement).mock.calls[0][0]).toMatchObject({
durationMinutes: 4320,
miteCount: 30,
});
});
it('„anders" 5 days sends 7200', async () => {
vi.mocked(createVarroaMeasurement).mockResolvedValue({ id: "v", colonyId: "colony-1" } as never);
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "anders" }));
await userEvent.type(screen.getByLabelText(/^Tage/), "5");
await submit();
expect(vi.mocked(createVarroaMeasurement).mock.calls[0][0]).toMatchObject({
durationMinutes: 7200,
});
});
it('shows „10,0 Milben/Tag – über der Schwelle" live', async () => {
renderPage();
await fillMiteCount("30");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
expect(
await screen.findByText(/10,0 Milben\/Tag – über der Schwelle, Behandlung prüfen/),
).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("über der Schwelle");
});
it("rate without traffic light when the rule is inactive", async () => {
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: null });
renderPage();
await fillMiteCount("30");
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
const status = await screen.findByText(/10,0 Milben\/Tag/);
expect(status).not.toHaveTextContent("Schwelle");
});
it("other methods keep the minutes field", async () => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "Waschmethode" }));
expect(screen.getByLabelText(/Zeitraum \(Minuten\)/)).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "3 Tage" })).not.toBeInTheDocument();
});
it("shows an inline error and focuses the date when it is missing", async () => {
renderPage();
await userEvent.clear(screen.getByLabelText(/^Datum/));
@@ -85,4 +154,18 @@ describe("NewVarroaMeasurementPage", () => {
expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
expect(createVarroaMeasurement).not.toHaveBeenCalled();
});
it("seeds the Tage field from the chosen chip when switching to anders", async () => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "3 Tage" }));
await userEvent.click(screen.getByRole("radio", { name: "anders" }));
expect(screen.getByLabelText(/^Tage/)).toHaveValue("3");
});
it("explains an invalid number of days instead of silently clearing", async () => {
renderPage();
await userEvent.click(screen.getByRole("radio", { name: "anders" }));
await userEvent.type(screen.getByLabelText(/^Tage/), "0");
expect(screen.getByRole("alert")).toHaveTextContent("größer als 0");
});
});
@@ -2,19 +2,24 @@
import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Bug } from "lucide-react";
import { createVarroaMeasurement } from "@/lib/varroa/api";
import { mitesPerDay } from "@/lib/varroa/rate";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
import { VarroaRateLight } from "@/components/varroa/varroa-rate-light";
import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold";
import { VARROA_METHODS, VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { ApiError } from "@/lib/auth/api-client";
import { queueVarroaMeasurement } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { useCaptureMutation } from "@/lib/offline/use-capture-mutation";
import { captureBackTarget } from "@/lib/forms/back-target";
import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Field } from "@/components/form/field";
@@ -46,6 +51,10 @@ function validate(values: VarroaFormValues): FieldErrors {
export default function NewVarroaMeasurementPage() {
const router = useRouter();
const searchParams = useSearchParams();
// Read once: the origin stays known even if the URL changes while typing.
const [backTarget] = useState(() =>
captureBackTarget(searchParams, { href: "/dashboard", label: "Zur Übersicht" }),
);
const queryClient = useQueryClient();
const [values, setValues] = useState<VarroaFormValues>(() =>
@@ -53,7 +62,6 @@ export default function NewVarroaMeasurementPage() {
);
const [dirty, setDirty] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const draft = useFormDraft("varroa-new", values, dirty);
function set<K extends keyof VarroaFormValues>(
@@ -67,43 +75,30 @@ export default function NewVarroaMeasurementPage() {
}
}
// One key per opened form: a double tap or an offline retry of the
// same entry is recognised by the server instead of saved twice.
const [idempotencyKey] = useState(newIdempotencyKey);
const mutation = useMutation({
mutationFn: (input: Parameters<typeof createVarroaMeasurement>[0]) =>
createVarroaMeasurement(input, { idempotencyKey }),
onSuccess: (measurement) => {
const capture = useCaptureMutation({
create: createVarroaMeasurement,
queue: queueVarroaMeasurement,
failureMessage: "Varroamessung konnte nicht gespeichert werden.",
onSaved: (outcome, input) => {
draft.clear();
if (outcome.via === "queued") {
toast.info(
"Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${input.colonyId}`);
return;
}
const measurement = outcome.result;
void queryClient.invalidateQueries({
queryKey: ["colonies", measurement.colonyId, "varroa-measurements"],
});
toast.success("Varroamessung gespeichert");
router.push(`/colonies/${measurement.colonyId}`);
},
onError: (mutationError, variables) => {
if (mutationError instanceof ApiError) {
setSubmitError(
errorMessage(
mutationError,
"Varroamessung konnte nicht gespeichert werden.",
),
);
return;
}
void queueVarroaMeasurement(variables, idempotencyKey).then(() => {
draft.clear();
toast.info(
"Kein Netzwerk – Varroamessung wurde offline gespeichert und wird synchronisiert.",
);
router.push(`/colonies/${variables.colonyId}`);
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
const errors = validate(values);
setFieldErrors(errors);
const firstInvalid = REQUIRED_KEYS.find((key) => errors[key]);
@@ -113,7 +108,7 @@ export default function NewVarroaMeasurementPage() {
?.focus();
return;
}
mutation.mutate(toVarroaInput(values));
capture.submit(toVarroaInput(values));
}
function restoreDraft() {
@@ -125,9 +120,22 @@ export default function NewVarroaMeasurementPage() {
}
const isNaturalFall = values.method === "NATURAL_MITE_FALL";
const threshold = useVarroaThreshold();
const rate = isNaturalFall
? mitesPerDay(
emptyToNull(values.miteCount, Number),
emptyToNull(values.durationMinutes, Number),
)
: null;
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<FormBackLink
href={backTarget.href}
label={backTarget.label}
dirty={dirty}
draft={draft}
/>
<PageHeader
icon={Bug}
title="Neue Varroamessung"
@@ -210,29 +218,39 @@ export default function NewVarroaMeasurementPage() {
/>
)}
</Field>
<Field
label="Zeitraum (Minuten)"
hint={
isNaturalFall
? "Wie lange die Windel lag: 1 Tag = 1440 Minuten, 3 Tage = 4320 Minuten."
: "Wie lange die Messung gedauert hat."
}
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.durationMinutes}
onChange={(event) =>
set("durationMinutes", event.target.value)
}
{isNaturalFall ? (
<>
<VarroaPeriodField
minutes={values.durationMinutes}
onChange={(minutes) => set("durationMinutes", minutes)}
hint="Wie lange die Windel lag."
/>
)}
</Field>
<VarroaRateLight
rate={rate}
threshold={threshold}
/>
</>
) : (
<Field
label="Zeitraum (Minuten)"
hint="Wie lange die Messung gedauert hat."
>
{({ id, describedBy }) => (
<Input
id={id}
aria-describedby={describedBy}
type="number"
inputMode="numeric"
min={0}
className="h-11"
value={values.durationMinutes}
onChange={(event) =>
set("durationMinutes", event.target.value)
}
/>
)}
</Field>
)}
</FormSection>
<FormSection
@@ -266,12 +284,12 @@ export default function NewVarroaMeasurementPage() {
<StickySubmit
note={
submitError ? (
capture.submitError ? (
<p
role="alert"
className="text-sm font-medium text-destructive"
>
{submitError}
{capture.submitError}
</p>
) : null
}
@@ -279,9 +297,9 @@ export default function NewVarroaMeasurementPage() {
<Button
type="submit"
className="h-12 text-base"
disabled={mutation.isPending}
disabled={capture.isPending}
>
{mutation.isPending ? "Wird gespeichert…" : "Messung speichern"}
{capture.isPending ? "Wird gespeichert…" : "Messung speichern"}
</Button>
</StickySubmit>
</form>
@@ -17,7 +17,7 @@ export interface VarroaFormValues {
/** One plain sentence per method: what is counted and how to read it. */
export const VARROA_METHOD_HINTS: Record<VarroaMethod, string> = {
NATURAL_MITE_FALL:
"Milben auf der Windel unter dem Gitterboden zählen. Gezählte Milben durch die Anzahl Tage teilen – nur dieser Milbenfall pro Tag lässt sich mit der Schadschwelle vergleichen.",
"Milben auf der Windel unter dem Gitterboden zählen. Zeitraum wählen – die App rechnet auf Milben pro Tag um. Nur dieser Milbenfall pro Tag lässt sich mit der Schadschwelle vergleichen.",
POWDERED_SUGAR:
"Bienenprobe mit Puderzucker bestäuben und schütteln, herausgefallene Milben zählen.",
WASHING_METHOD:
@@ -67,6 +67,31 @@ describe("LoginPage", () => {
expect(await screen.findByRole("alert")).toHaveTextContent(/E-Mail oder Passwort falsch/i);
});
it("shows a connection message instead of a wrong-password hint on a timeout", async () => {
loginMock.mockRejectedValueOnce(new DOMException("t", "TimeoutError"));
render(<LoginPage />);
await userEvent.type(screen.getByLabelText("E-Mail"), "[email protected]");
await userEvent.type(screen.getByLabelText("Passwort"), "ChangeMe123!");
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Keine Verbindung zum Server/i);
});
it("shows a connection message when the MFA verification times out", async () => {
loginMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "mfa-token" });
verifyMfaMock.mockRejectedValueOnce(new DOMException("t", "TimeoutError"));
render(<LoginPage />);
await userEvent.type(screen.getByLabelText("E-Mail"), "[email protected]");
await userEvent.type(screen.getByLabelText("Passwort"), "ChangeMe123!");
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
await userEvent.type(await screen.findByLabelText("Code"), "000000");
await userEvent.click(screen.getByRole("button", { name: /bestätigen/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Keine Verbindung zum Server/i);
});
it("shows the code step and verifies MFA when required", async () => {
loginMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "mfa-token" });
verifyMfaMock.mockResolvedValueOnce(undefined);
+12 -4
View File
@@ -1,5 +1,6 @@
"use client";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { FormEvent, Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Hexagon } from "lucide-react";
@@ -25,6 +26,9 @@ const SSO_ERROR_MESSAGES: Record<string, string> = {
"Kein ApiCore-Konto mit dieser E-Mail-Adresse gefunden. Bitte an eine Administratorin oder einen Administrator wenden.",
};
const NETWORK_ERROR_MESSAGE =
"Keine Verbindung zum Server. Bitte Verbindung prüfen und erneut versuchen.";
export default function LoginPage() {
return (
<Suspense fallback={null}>
@@ -106,9 +110,11 @@ function LoginPageContent() {
} else {
router.push("/dashboard");
}
} catch {
} catch (caught) {
setError(
"E-Mail oder Passwort falsch. Nach 5 Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.",
isNetworkError(caught)
? NETWORK_ERROR_MESSAGE
: "E-Mail oder Passwort falsch. Nach 5 Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.",
);
} finally {
setIsSubmitting(false);
@@ -125,9 +131,11 @@ function LoginPageContent() {
try {
await verifyMfa(mfaToken, code);
router.push("/dashboard");
} catch {
} catch (caught) {
setError(
"Code ungültig oder schon benutzt. Bitte den nächsten Code aus der App eingeben.",
isNetworkError(caught)
? NETWORK_ERROR_MESSAGE
: "Code ungültig oder schon benutzt. Bitte den nächsten Code aus der App eingeben.",
);
} finally {
setIsSubmitting(false);
+3 -3
View File
@@ -98,15 +98,15 @@
--success: oklch(0.46 0.06 145);
--success-foreground: oklch(0.98 0.015 100);
--condition-excellent: oklch(0.58 0.13 145);
--condition-excellent-foreground: oklch(0.24 0.05 145);
--condition-excellent-foreground: oklch(0.16 0.04 145);
--condition-good: oklch(0.64 0.13 118);
--condition-good-foreground: oklch(0.26 0.05 118);
--condition-average: oklch(0.7 0.14 90);
--condition-average-foreground: oklch(0.28 0.05 90);
--condition-weak: oklch(0.68 0.15 55);
--condition-weak-foreground: oklch(0.26 0.05 55);
--condition-critical: oklch(0.55 0.16 30);
--condition-critical-foreground: oklch(0.24 0.05 30);
--condition-critical: oklch(0.64 0.16 30);
--condition-critical-foreground: oklch(0.15 0.04 30);
--chart-1: oklch(0.6 0.135 50);
--chart-2: oklch(0.46 0.06 145);
--chart-3: oklch(0.68 0.14 75);
@@ -16,6 +16,7 @@ import {
type LucideIcon,
} from "lucide-react";
import { fetchPublicBatch } from "@/lib/public-batch/api";
import { originText } from "@/lib/public-batch/origin-text";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
@@ -99,14 +100,6 @@ export default function PublicBatchPage() {
const harvestMonths = batch
? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
: null;
// Standort statt Bienenstand-Name: mehrere Bienenstände am selben Ort
// sollen als ein Herkunftsort erscheinen, nicht als mehrere interne Namen.
// Fällt auf den Namen zurück, falls für einen Bienenstand kein Standort
// hinterlegt ist, damit die Zeile nie leer bleibt.
const originLabels = batch
? [...new Set(batch.apiaries.map((a) => a.location?.trim() || a.name))]
: [];
const originText = originLabels.join(", ");
const socialLinks = batch
? [
{ label: "YouTube", url: batch.socialLinks.youtube },
@@ -163,9 +156,7 @@ export default function PublicBatchPage() {
) : null}
<dl className="flex flex-col gap-3 text-sm">
{originText ? (
<InfoRow icon={MapPin} label="Herkunft" value={originText} />
) : null}
<InfoRow icon={MapPin} label="Herkunft" value={originText(batch.origin)} />
{harvestMonths ? (
<InfoRow icon={CalendarDays} label="Geerntet" value={harvestMonths} />
) : null}
@@ -0,0 +1,40 @@
import { useMutation } from "@tanstack/react-query";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Providers } from "./providers";
vi.mock("@/lib/auth/auth-context", () => ({
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
function Probe({ mutationFn }: { mutationFn: () => Promise<string> }) {
const mutation = useMutation({ mutationFn });
return <button onClick={() => mutation.mutate()}>Speichern</button>;
}
describe("Providers", () => {
afterEach(() => {
vi.restoreAllMocks();
// TanStack's onlineManager tracks the browser events, so restore it too.
act(() => {
window.dispatchEvent(new Event("online"));
});
});
it("runs mutations while navigator.onLine is false", async () => {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false);
const mutationFn = vi.fn().mockResolvedValue("ok");
render(
<Providers>
<Probe mutationFn={mutationFn} />
</Providers>,
);
// onlineManager (listening once mounted) only learns about airplane mode through the window event.
act(() => {
window.dispatchEvent(new Event("offline"));
});
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(mutationFn).toHaveBeenCalledTimes(1));
});
});
+16 -1
View File
@@ -2,11 +2,26 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { ApiError } from "@/lib/auth/api-client";
import { AuthProvider } from "@/lib/auth/auth-context";
import { applyMotionLevel, readMotionLevel } from "@/lib/motion/motion-preference";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
// offlineFirst: serve cache / try the request even without a network
// signal; 'always' mutations run so forms can queue instead of hanging.
queries: {
networkMode: "offlineFirst",
retry: (failureCount, error) =>
!(error instanceof ApiError && error.status < 500) && failureCount < 1,
},
mutations: { networkMode: "always", retry: false },
},
}),
);
useEffect(() => {
applyMotionLevel(readMotionLevel());
@@ -0,0 +1,22 @@
import { AlertTriangle, CheckCircle2, XCircle, type LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { COLONY_CONDITION_TONE_CLASS } from "@/lib/colonies/labels";
import type { StatusLight } from "@/lib/colonies/status-light";
import { cn } from "@/lib/utils";
const LIGHTS: Record<StatusLight, { label: string; icon: LucideIcon; tone: string }> = {
green: { label: "Gut", icon: CheckCircle2, tone: COLONY_CONDITION_TONE_CLASS.GOOD },
yellow: { label: "Achtung", icon: AlertTriangle, tone: COLONY_CONDITION_TONE_CLASS.AVERAGE },
red: { label: "Kritisch", icon: XCircle, tone: COLONY_CONDITION_TONE_CLASS.CRITICAL },
};
/** The traffic light as icon plus word - the colour only underlines it. */
export function StatusLightBadge({ light, className }: { light: StatusLight; className?: string }) {
const { label, icon: Icon, tone } = LIGHTS[light];
return (
<Badge className={cn(tone, className)}>
<Icon aria-hidden />
{label}
</Badge>
);
}
@@ -15,6 +15,11 @@ import type { VariantProps } from "class-variance-authority";
interface ConfirmDialogProps {
triggerLabel: string;
/**
* Accessible name when several triggers share one visible label (e.g. a
* „Verwerfen" per list row). Start it with the visible label (WCAG 2.5.3).
*/
triggerAriaLabel?: string;
triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
triggerSize?: VariantProps<typeof buttonVariants>["size"];
triggerClassName?: string;
@@ -29,6 +34,7 @@ interface ConfirmDialogProps {
export function ConfirmDialog({
triggerLabel,
triggerAriaLabel,
triggerVariant = "outline",
triggerSize = "default",
triggerClassName,
@@ -49,6 +55,7 @@ export function ConfirmDialog({
variant={triggerVariant}
size={triggerSize}
className={cn(triggerClassName)}
aria-label={triggerAriaLabel}
disabled={triggerDisabled}
onClick={() => setOpen(true)}
>
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDocuments, updateDocument, uploadDocument } from "@/lib/documents/api";
import { MAX_DOCUMENT_UPLOAD_BYTES } from "@/lib/documents/limits";
import { PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
import { DocumentUploadCard } from "./document-upload-card";
vi.mock("@/lib/documents/api", () => ({
fetchDocuments: vi.fn(),
uploadDocument: vi.fn(),
updateDocument: vi.fn(),
deleteDocument: vi.fn(),
downloadDocument: vi.fn(),
}));
vi.mock("@/lib/photos/reencode", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/photos/reencode")>()),
preparePhoto: vi.fn(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const CLEAN_PHOTO = new Blob(["clean jpeg"], { type: "image/jpeg" });
const STORED = {
id: "doc-1",
entityType: "INSPECTION" as const,
entityId: "i-1",
fileName: "rahmen.jpg",
mimeType: "image/jpeg",
sizeBytes: 2048,
uploadedByUserId: "user-1",
createdAt: "2026-10-13T10:00:00.000Z",
};
function fileInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("no file input");
return input;
}
function renderCard(onOuterSubmit?: () => void) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const card = <DocumentUploadCard entityType="INSPECTION" entityId="i-1" />;
return render(
<QueryClientProvider client={client}>
{onOuterSubmit ? (
<form
onSubmit={(event) => {
event.preventDefault();
onOuterSubmit();
}}
>
{card}
</form>
) : (
card
)}
</QueryClientProvider>,
);
}
describe("DocumentUploadCard", () => {
beforeEach(() => {
vi.mocked(fetchDocuments).mockReset().mockResolvedValue([]);
vi.mocked(uploadDocument).mockReset().mockResolvedValue(STORED);
vi.mocked(updateDocument).mockReset().mockResolvedValue(STORED);
vi.mocked(preparePhoto).mockReset().mockResolvedValue(CLEAN_PHOTO);
vi.mocked(toast.error).mockReset();
});
it("re-encodes images before upload, PDFs unchanged", async () => {
const { container } = renderCard();
const photo = new File(["png with metadata"], "Beute 3.png", { type: "image/png" });
const invoice = new File(["%PDF-1.7"], "rechnung.pdf", { type: "application/pdf" });
await userEvent.upload(fileInput(container), photo);
await waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1));
await userEvent.upload(fileInput(container), invoice);
await waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(2));
expect(preparePhoto).toHaveBeenCalledTimes(1);
expect(preparePhoto).toHaveBeenCalledWith(photo);
expect(uploadDocument).toHaveBeenNthCalledWith(1, "INSPECTION", "i-1", CLEAN_PHOTO, {
fileName: "Beute 3.jpg",
});
expect(uploadDocument).toHaveBeenNthCalledWith(2, "INSPECTION", "i-1", invoice);
});
it("does not upload a photo that stays too large, and says why", async () => {
vi.mocked(preparePhoto).mockRejectedValue(new PhotoTooLargeError());
const { container } = renderCard();
await userEvent.upload(
fileInput(container),
new File(["huge"], "panorama.jpg", { type: "image/jpeg" }),
);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Foto zu groß (max. 10 MB)"),
);
expect(uploadDocument).not.toHaveBeenCalled();
});
it("rejects a PDF above the server's upload limit before sending it", async () => {
const { container } = renderCard();
const invoice = new File(["%PDF-1.7"], "scan.pdf", { type: "application/pdf" });
Object.defineProperty(invoice, "size", { value: MAX_DOCUMENT_UPLOAD_BYTES + 1 });
await userEvent.upload(fileInput(container), invoice);
expect(toast.error).toHaveBeenCalledWith("Datei zu groß (max. 10 MB)");
expect(uploadDocument).not.toHaveBeenCalled();
});
it("renaming a file never submits the form the card sits in", async () => {
vi.mocked(fetchDocuments).mockResolvedValue([STORED]);
const outerSubmit = vi.fn();
renderCard(outerSubmit);
await userEvent.click(await screen.findByRole("button", { name: "Umbenennen" }));
const dialog = await screen.findByRole("dialog", { name: "Datei umbenennen" });
await userEvent.clear(within(dialog).getByLabelText("Dateiname"));
await userEvent.type(within(dialog).getByLabelText("Dateiname"), "brutwabe.jpg{Enter}");
await waitFor(() =>
expect(updateDocument).toHaveBeenCalledWith("doc-1", { fileName: "brutwabe.jpg" }),
);
expect(outerSubmit).not.toHaveBeenCalled();
});
});
@@ -12,6 +12,8 @@ import {
uploadDocument,
} from "@/lib/documents/api";
import { DocumentEntityType, DocumentSummary } from "@/lib/documents/types";
import { MAX_DOCUMENT_UPLOAD_BYTES } from "@/lib/documents/limits";
import { PhotoMetadataError, PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -28,7 +30,42 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
const TOO_LARGE_MESSAGE = "Datei zu groß (max. 10 MB)";
function isImage(file: File): boolean {
return file.type.startsWith("image/");
}
/** "Beute 3.png" -> "Beute 3.jpg": a re-encoded image is always a JPEG. */
function jpegName(fileName: string): string {
const dot = fileName.lastIndexOf(".");
return `${dot > 0 ? fileName.slice(0, dot) : fileName}.jpg`;
}
/**
* Images are re-encoded first (preparePhoto): what reaches the server never
* carries the camera's EXIF/GPS - an apiary's position is theft-relevant.
* PDFs go up unchanged.
*/
async function uploadFile(
entityType: DocumentEntityType,
entityId: string,
file: File,
): Promise<DocumentSummary> {
if (!isImage(file)) {
return uploadDocument(entityType, entityId, file);
}
const photo = await preparePhoto(file);
return uploadDocument(entityType, entityId, photo, { fileName: jpegName(file.name) });
}
function uploadErrorMessage(error: unknown): string {
if (error instanceof PhotoTooLargeError) return "Foto zu groß (max. 10 MB)";
if (error instanceof PhotoMetadataError) {
return "Foto wurde nicht hochgeladen: Die Standortdaten ließen sich nicht entfernen.";
}
return "Datei konnte nicht hochgeladen werden";
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -60,6 +97,9 @@ function RenameDocumentDialog({ document }: { document: DocumentSummary }) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// The dialog sits in a portal, but React still bubbles the submit up the
// component tree: it must not also submit a form the card is placed in.
event.stopPropagation();
mutation.mutate();
}
@@ -127,12 +167,12 @@ export function DocumentUploadCard({
});
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadDocument(entityType, entityId, file),
mutationFn: (file: File) => uploadFile(entityType, entityId, file),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
toast.success("Datei hochgeladen");
},
onError: () => toast.error("Datei konnte nicht hochgeladen werden"),
onError: (error) => toast.error(uploadErrorMessage(error)),
});
const deleteMutation = useMutation({
@@ -152,8 +192,10 @@ export function DocumentUploadCard({
toast.error("Nicht unterstützter Dateityp (nur JPEG, PNG, WebP, PDF)");
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
toast.error("Datei zu groß (max. 10 MB)");
// An image may shrink below the limit when it is re-encoded; preparePhoto
// checks the result.
if (!isImage(file) && file.size > MAX_DOCUMENT_UPLOAD_BYTES) {
toast.error(TOO_LARGE_MESSAGE);
return;
}
uploadMutation.mutate(file);
@@ -15,6 +15,8 @@ export interface ChoiceOption<T extends ChoiceValue> {
* the visible label (voice control users say what they see).
*/
srSuffix?: string;
/** Replaces the default selected look, e.g. a tone colour of a rating step. */
selectedClassName?: string;
}
/** Distinct per value and type: "null" (text) and null must not collide. */
@@ -131,6 +133,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
selected &&
!isUnanswered &&
"border-primary bg-primary font-medium text-primary-foreground",
selected && option.selectedClassName,
optionClassName,
)}
>
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FormBackLink } from "./form-back-link";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
}));
describe("FormBackLink", () => {
beforeEach(() => push.mockReset());
it("is a plain link when nothing was typed", () => {
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty={false} />);
expect(screen.getByRole("link", { name: /Zu den Völkern/ })).toHaveAttribute(
"href",
"/colonies",
);
});
it("asks before leaving and flushes the draft", async () => {
const flush = vi.fn();
render(
<FormBackLink href="/colonies" label="Zu den Völkern" dirty draft={{ flush }} />,
);
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
expect(push).not.toHaveBeenCalled();
expect(
await screen.findByText(/Deine Eingaben bleiben als Entwurf auf diesem Gerät\./),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Verlassen" }));
expect(flush).toHaveBeenCalledTimes(1);
expect(push).toHaveBeenCalledWith("/colonies");
});
it("stays on the form with Weiter bearbeiten", async () => {
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty />);
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
await userEvent.click(await screen.findByRole("button", { name: "Weiter bearbeiten" }));
expect(push).not.toHaveBeenCalled();
});
it("warns that input is lost when there is no draft", async () => {
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty />);
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
expect(await screen.findByText(/Deine Eingaben gehen verloren\./)).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
"use client";
import { MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export interface FormBackLinkProps {
href: string;
label: string;
/** Something was typed in: leaving needs a confirmation. */
dirty: boolean;
/** The form's draft; flushed so leaving does not race the autosave delay. */
draft?: { flush: () => void };
}
const NOT_PLAIN_CLICK = (event: MouseEvent) =>
event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
/**
* „Zurück" above a new-form's header. A plain link while the form is
* untouched; once something was typed it asks first, saying honestly whether
* the input stays as a draft on this device or is lost.
*/
export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
function handleClick(event: MouseEvent<HTMLAnchorElement>) {
// Modified clicks open a new tab: the form here stays untouched.
if (!dirty || NOT_PLAIN_CLICK(event)) return;
event.preventDefault();
setOpen(true);
}
function leave() {
draft?.flush();
setOpen(false);
router.push(href);
}
return (
<>
<Link
href={href}
onClick={handleClick}
className="-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<ArrowLeft className="size-4" aria-hidden="true" />
{label}
</Link>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Formular verlassen?</DialogTitle>
<DialogDescription>
{draft
? "Deine Eingaben bleiben als Entwurf auf diesem Gerät."
: "Deine Eingaben gehen verloren."}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" onClick={() => setOpen(false)}>
Weiter bearbeiten
</Button>
<Button type="button" variant="outline" onClick={leave}>
Verlassen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NumberStepper } from "./number-stepper";
function Harness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return (
<NumberStepper
id="mites"
value={value}
onChange={setValue}
unitLabel="Milben"
decrementLabel="Eine Milbe weniger"
incrementLabel="Eine Milbe mehr"
/>
);
}
describe("NumberStepper", () => {
it('„+" from empty gives 1', async () => {
render(<Harness />);
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(screen.getByRole("textbox")).toHaveValue("1");
});
it('„−" is disabled at 0', () => {
render(<Harness initial="0" />);
expect(screen.getByRole("button", { name: /Eine Milbe weniger/ })).toBeDisabled();
});
it('„−" is disabled while empty', () => {
render(<Harness />);
expect(screen.getByRole("button", { name: /Eine Milbe weniger/ })).toBeDisabled();
});
it('typing 7 then „+" gives 8', async () => {
render(<Harness />);
await userEvent.type(screen.getByRole("textbox"), "7");
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(screen.getByRole("textbox")).toHaveValue("8");
});
it('„−" steps down from 8 to 7', async () => {
render(<Harness initial="8" />);
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe weniger/ }));
expect(screen.getByRole("textbox")).toHaveValue("7");
});
it("drops non-digits while typing", async () => {
render(<Harness />);
await userEvent.type(screen.getByRole("textbox"), "1a2");
expect(screen.getByRole("textbox")).toHaveValue("12");
});
it('„leeren" clears to empty and only shows with a value', async () => {
render(<Harness />);
expect(screen.queryByRole("button", { name: /leeren/ })).not.toBeInTheDocument();
await userEvent.type(screen.getByRole("textbox"), "5");
await userEvent.click(screen.getByRole("button", { name: /leeren/ }));
expect(screen.getByRole("textbox")).toHaveValue("");
});
it("moves focus to the input when „−“ turns disabled", async () => {
render(<Harness initial="1" />);
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe weniger/ }));
expect(screen.getByRole("textbox")).toHaveValue("0");
expect(screen.getByRole("textbox")).toHaveFocus();
});
it("moves focus to the input after „leeren“", async () => {
render(<Harness initial="5" />);
await userEvent.click(screen.getByRole("button", { name: /leeren/ }));
expect(screen.getByRole("textbox")).toHaveFocus();
});
it("caps the value so „+“ never produces huge numbers", async () => {
render(<Harness initial="9999" />);
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "4");
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
expect(screen.getByRole("textbox")).toHaveValue("9999");
});
it("buttons are named and 44 px", () => {
render(<Harness initial="3" />);
for (const name of [/Eine Milbe weniger/, /Eine Milbe mehr/, /leeren/]) {
expect(screen.getByRole("button", { name })).toHaveClass("min-h-11");
}
expect(screen.getByRole("textbox")).toHaveAccessibleDescription("Milben");
});
});
@@ -0,0 +1,123 @@
"use client";
import { ChangeEvent, useRef } from "react";
import { Input } from "@/components/ui/input";
export interface NumberStepperProps {
id: string;
/** "" = not counted. */
value: string;
onChange: (value: string) => void;
min?: number;
step?: number;
/** Visible unit next to the field, e.g. "Milben"; the name comes from the Field label. */
unitLabel: string;
decrementLabel: string;
incrementLabel: string;
describedBy?: string;
invalid?: boolean;
}
const STEP_BUTTON_CLASS =
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold outline-none motion-safe:transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50";
const MAX_DIGITS = 4;
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
function digitsOnly(text: string): string {
return text.replace(/\D/g, "");
}
/**
* A count with big plus and minus buttons for gloved fingers; the field stays
* typable. Whole counts only: digits only (no negatives or decimals), at most
* 4 digits. Empty means "not counted": "+" then starts at min + step, "−" is
* off, and "leeren" goes back to empty (a 0 would read as "counted: none").
*/
export function NumberStepper({
id,
value,
onChange,
min = 0,
step = 1,
unitLabel,
decrementLabel,
incrementLabel,
describedBy,
invalid,
}: NumberStepperProps) {
const unitId = `${id}-unit`;
const current = value === "" ? null : Number(value);
const canDecrement = current !== null && current - step >= min;
const inputRef = useRef<HTMLInputElement>(null);
function increment() {
onChange(String(Math.min(MAX_VALUE, current === null ? min + step : current + step)));
}
function decrement() {
if (!canDecrement) return;
const next = current - step;
onChange(String(next));
// A button that turns disabled would drop keyboard focus to the page.
if (next - step < min) inputRef.current?.focus();
}
function clear() {
onChange("");
inputRef.current?.focus();
}
function handleInput(event: ChangeEvent<HTMLInputElement>) {
onChange(digitsOnly(event.target.value));
}
return (
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={decrement}
disabled={!canDecrement}
className={STEP_BUTTON_CLASS}
>
−<span className="sr-only"> {decrementLabel}</span>
</button>
<Input
id={id}
ref={inputRef}
type="text"
maxLength={MAX_DIGITS}
inputMode="numeric"
autoComplete="off"
value={value}
onChange={handleInput}
aria-describedby={[describedBy, unitId].filter(Boolean).join(" ")}
aria-invalid={invalid || undefined}
className="w-20 text-center text-lg font-semibold sm:h-11"
/>
<button
type="button"
onClick={increment}
className={STEP_BUTTON_CLASS}
>
+<span className="sr-only"> {incrementLabel}</span>
</button>
<span id={unitId} className="text-sm text-muted-foreground">
{unitLabel}
</span>
</div>
{value !== "" ? (
<button
type="button"
onClick={clear}
className="min-h-11 rounded-lg px-2 text-sm text-muted-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
leeren{" "}
<span className="sr-only">– {unitLabel}</span>
</button>
) : null}
</div>
);
}
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PreviousValueHint } from "./previous-value-hint";
describe("PreviousValueHint", () => {
it("announces and takes the value over", async () => {
const onTakeOver = vi.fn();
render(
<PreviousValueHint fieldLabel="Königin gesehen" valueLabel="Ja" onTakeOver={onTakeOver} />,
);
expect(screen.getByText(/Letztes Mal: Ja/)).toBeInTheDocument();
const button = screen.getByRole("button", { name: /übernehmen/ });
expect(button).toHaveAccessibleName("übernehmen – Königin gesehen");
expect(button).toHaveClass("min-h-11");
await userEvent.click(button);
expect(onTakeOver).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,40 @@
"use client";
import { cn } from "@/lib/utils";
export interface PreviousValueHintProps {
/** Name of the field, spoken after "übernehmen" so the button is unambiguous. */
fieldLabel: string;
/** The earlier answer in words, e.g. "Ja" or "4 von 5". */
valueLabel: string;
onTakeOver: () => void;
className?: string;
}
/**
* "Letztes Mal: Ja · übernehmen". Observations are never prefilled - what
* the beekeeper did not look at today must not read as seen. The last value
* is only shown, and one tap takes it over.
*/
export function PreviousValueHint({
fieldLabel,
valueLabel,
onTakeOver,
className,
}: PreviousValueHintProps) {
return (
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
<span>
Letztes Mal: {valueLabel} <span aria-hidden="true">·</span>
</span>
<button
type="button"
onClick={onTakeOver}
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
übernehmen{" "}
<span className="sr-only">– {fieldLabel}</span>
</button>
</p>
);
}
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RatingScale } from "./rating-scale";
import type { RatingDirection } from "@/lib/forms/rating";
function Harness({
initial,
direction,
onChange,
}: {
initial: number | null;
direction?: RatingDirection;
onChange?: (value: number | null) => void;
}) {
const [value, setValue] = useState(initial);
return (
<RatingScale
id="mood"
value={value}
direction={direction}
lowLabel="ruhig"
highLabel="stark"
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
/>
);
}
describe("RatingScale", () => {
it("lower-is-better shows stored 5 as display 1 on the left", () => {
render(<Harness initial={5} direction="lower-is-better" />);
const radios = within(screen.getByRole("radiogroup")).getAllByRole("radio");
expect(radios[0]).toHaveAttribute("aria-checked", "true");
expect(radios[0]).toHaveTextContent("1");
});
it("tapping display 5 stores 1", async () => {
const onChange = vi.fn();
render(<Harness initial={null} direction="lower-is-better" onChange={onChange} />);
await userEvent.click(screen.getAllByRole("radio")[4]);
expect(onChange).toHaveBeenLastCalledWith(1);
});
it("keeps stored values unchanged for higher-is-better", async () => {
const onChange = vi.fn();
render(<Harness initial={null} onChange={onChange} />);
await userEvent.click(screen.getAllByRole("radio")[3]);
expect(onChange).toHaveBeenLastCalledWith(4);
});
it("tapping the chosen value again clears to null", async () => {
const onChange = vi.fn();
render(<Harness initial={3} onChange={onChange} />);
await userEvent.click(screen.getAllByRole("radio")[2]);
expect(onChange).toHaveBeenLastCalledWith(null);
});
it("the selected option carries the tone of its display value", () => {
const { unmount } = render(<Harness initial={5} direction="lower-is-better" />);
expect(screen.getAllByRole("radio")[0]).toHaveClass("bg-condition-critical");
unmount();
render(<Harness initial={5} />);
expect(screen.getAllByRole("radio")[4]).toHaveClass("bg-condition-excellent");
});
it("lower-is-better swaps the stored end words so the bad end stays left", async () => {
const onChange = vi.fn();
render(<Harness initial={null} direction="lower-is-better" onChange={onChange} />);
expect(screen.getByText("1 = stark")).toBeInTheDocument();
expect(screen.getByText("5 = ruhig")).toBeInTheDocument();
await userEvent.click(screen.getAllByRole("radio")[0]);
expect(onChange).toHaveBeenLastCalledWith(5);
});
it("higher-is-better labels the stored ends as given", () => {
render(<Harness initial={null} />);
expect(screen.getByText("1 = ruhig")).toBeInTheDocument();
expect(screen.getByText("5 = stark")).toBeInTheDocument();
});
});
@@ -1,18 +1,36 @@
"use client";
import {
RATING_TONE_CLASS,
toDisplayRating,
toStoredRating,
type RatingDirection,
} from "@/lib/forms/rating";
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
const RATING_OPTIONS: readonly ChoiceOption<number | null>[] = [1, 2, 3, 4, 5].map(
(rating) => ({ value: rating, label: String(rating), srSuffix: "von 5" }),
);
const DISPLAY_STEPS = [1, 2, 3, 4, 5] as const;
const RATING_OPTIONS: readonly ChoiceOption<number | null>[] = DISPLAY_STEPS.map((step) => ({
value: step,
label: String(step),
srSuffix: "von 5",
selectedClassName: RATING_TONE_CLASS[step],
}));
interface RatingScaleProps {
id: string;
/** Stored value; with `direction` it is mirrored for display only. */
value: number | null;
onChange: (value: number | null) => void;
/** Words for the ends, e.g. "stechlustig" / "sehr sanft". */
/**
* Words for the STORED ends (lowLabel = stored 1, highLabel = stored 5).
* With `direction="lower-is-better"` they are shown swapped, so the bad end
* stays on the left.
*/
lowLabel: string;
highLabel: string;
/** Which stored end is good; the good end is always shown on the right. */
direction?: RatingDirection;
describedBy?: string;
/** Id of the visible label, so the group is announced by name. */
labelledBy?: string;
@@ -20,8 +38,9 @@ interface RatingScaleProps {
/**
* 1-5 as five large buttons instead of a number field: one tap with gloves,
* no keyboard, and the meaning of both ends stays on screen. Tapping the
* chosen value again clears it.
* no keyboard, and the meaning of both ends stays on screen. Left is always
* the bad end, right the good one, and a chosen step takes the tone of its
* displayed value. Tapping the chosen value again clears it.
*/
export function RatingScale({
id,
@@ -29,9 +48,12 @@ export function RatingScale({
onChange,
lowLabel,
highLabel,
direction = "higher-is-better",
describedBy,
labelledBy,
}: RatingScaleProps) {
const [leftLabel, rightLabel] =
direction === "lower-is-better" ? [highLabel, lowLabel] : [lowLabel, highLabel];
return (
<div className="flex flex-col gap-1">
<ChoiceGroup
@@ -39,15 +61,15 @@ export function RatingScale({
labelledBy={labelledBy}
describedBy={describedBy}
options={RATING_OPTIONS}
value={value}
value={toDisplayRating(value, direction)}
clearValue={null}
onChange={onChange}
onChange={(display) => onChange(toStoredRating(display, direction))}
className="grid-cols-5"
optionClassName="text-base font-semibold"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1 = {lowLabel}</span>
<span>5 = {highLabel}</span>
<span>1 = {leftLabel}</span>
<span>5 = {rightLabel}</span>
</div>
</div>
);
@@ -57,4 +57,31 @@ describe("TriStateField", () => {
expect(screen.getByRole("radio", { name: "Nein" })).toHaveAttribute("aria-checked", "true");
});
it("shows „Letztes Mal: Ja · übernehmen“ and takes it over", async () => {
const onChange = vi.fn();
render(
<TriStateField
label="Königin gesehen"
value={null}
onChange={onChange}
previous={{ valueLabel: "Ja", onTakeOver: () => onChange(true) }}
/>,
);
expect(screen.getByText(/Letztes Mal: Ja/)).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveAttribute(
"aria-checked",
"true",
);
await userEvent.click(screen.getByRole("button", { name: /übernehmen/ }));
expect(onChange).toHaveBeenCalledWith(true);
});
it("shows no hint without a previous value", () => {
render(<Harness initial={null} onChange={vi.fn()} />);
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
});
});
@@ -2,6 +2,7 @@
import { useId } from "react";
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
import { PreviousValueHint } from "./previous-value-hint";
export const TRI_STATE_OPTIONS: readonly ChoiceOption<boolean | null>[] = [
{ value: true, label: "Ja" },
@@ -13,6 +14,8 @@ interface TriStateFieldProps {
label: string;
value: boolean | null;
onChange: (value: boolean | null) => void;
/** Last time's answer, offered for one-tap take-over - never prefilled. */
previous?: { valueLabel: string; onTakeOver: () => void } | null;
}
/**
@@ -20,7 +23,7 @@ interface TriStateFieldProps {
* "Nicht geprüft" (null), so only what the beekeeper actually looked at is
* saved as a finding - an untouched "no" would otherwise read as "seen: none".
*/
export function TriStateField({ label, value, onChange }: TriStateFieldProps) {
export function TriStateField({ label, value, onChange, previous }: TriStateFieldProps) {
const labelId = useId();
return (
<div className="flex flex-col gap-1.5">
@@ -34,6 +37,13 @@ export function TriStateField({ label, value, onChange }: TriStateFieldProps) {
onChange={onChange}
className="grid-cols-3"
/>
{previous ? (
<PreviousValueHint
fieldLabel={label}
valueLabel={previous.valueLabel}
onTakeOver={previous.onTakeOver}
/>
) : null}
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
/** A tappable row in a nav sheet: 56px high for gloved or hurried thumbs. */
export const NAV_SHEET_ROW_CLASS =
"flex min-h-14 items-center gap-3 rounded-xl px-3 text-base font-medium focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none";
interface NavSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The `SheetTrigger` that opens the sheet. */
trigger: ReactNode;
title: string;
/** Extra classes for the sheet panel. */
className?: string;
/** Where focus goes on close; by default back to the trigger. */
finalFocus?: ComponentProps<typeof SheetContent>["finalFocus"];
children: ReactNode;
}
/**
* The shared bottom sheet of „Mehr", „+ Neu" and the offline queue: a title,
* a German close button and a scroll area that clears the iOS home indicator.
*/
export function NavSheet({
open,
onOpenChange,
trigger,
title,
className,
finalFocus,
children,
}: NavSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
{trigger}
<SheetContent
side="bottom"
// The built-in close button is English; ours is German and 44px high.
showCloseButton={false}
finalFocus={finalFocus}
className={[
"max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)",
className,
]
.filter(Boolean)
.join(" ")}
>
<SheetHeader className="flex-row items-center justify-between">
<SheetTitle>{title}</SheetTitle>
<SheetClose
render={<Button variant="ghost" size="sm" className="min-h-11" />}
>
Schließen
</SheetClose>
</SheetHeader>
{children}
</SheetContent>
</Sheet>
);
}
@@ -1,78 +0,0 @@
"use client";
import { useEffect } from "react";
import { CloudOff, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge } from "@/components/ui/badge";
import { countAllPending } from "@/lib/offline/db";
import { syncPendingInspections } from "@/lib/offline/sync";
const PENDING_QUERY_KEY = ["offline-pending-entries"];
export function OfflineSyncBadge() {
const queryClient = useQueryClient();
const { data: pending = 0 } = useQuery({
queryKey: PENDING_QUERY_KEY,
queryFn: countAllPending,
refetchInterval: 15000,
});
const syncMutation = useMutation({
mutationFn: syncPendingInspections,
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: PENDING_QUERY_KEY });
if (result.synced > 0) {
toast.success(`${result.synced} Eintrag/Einträge synchronisiert`);
}
if (result.failed > 0) {
toast.error(
`${result.failed} Eintrag/Einträge konnten nicht synchronisiert werden und bleiben in der Warteschlange`,
);
}
},
onError: () =>
toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht"),
});
useEffect(() => {
function handleOnline() {
syncMutation.mutate();
}
window.addEventListener("online", handleOnline);
if (navigator.onLine) {
syncMutation.mutate();
}
return () => window.removeEventListener("online", handleOnline);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (pending === 0) {
return null;
}
return (
<Badge
variant="warning"
role="button"
tabIndex={0}
className="cursor-pointer"
title="Ausstehende Einträge synchronisieren"
onClick={() => syncMutation.mutate()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
syncMutation.mutate();
}
}}
>
{syncMutation.isPending ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
<CloudOff className="size-3.5" />
)}
{pending} ausstehend
</Badge>
);
}
@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRef } from "react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { discardQueueItem, listQueue, summarizeQueue, type QueueItem } from "@/lib/offline/db";
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
import { OfflineBand } from "./offline-band";
vi.mock("@/lib/offline/db", () => ({
summarizeQueue: vi.fn(),
listQueue: vi.fn(),
discardQueueItem: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn(), resumeSync: vi.fn() }));
const NOTHING_SENT = { synced: 0, failed: 0, skippedForeign: 0 };
const REJECTED: QueueItem = {
store: "pending-inspections",
id: 7,
label: "Durchsicht",
queuedAt: "2026-10-13T08:15:00.000Z",
lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" },
};
const WAITING: QueueItem = {
store: "pending-feedings",
id: 3,
label: "Fütterung",
queuedAt: "2026-10-13T08:20:00.000Z",
};
function setOnLine(value: boolean) {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(value);
}
/** The band as the layout places it: above the page content wrapper. */
function BandOnPage() {
const pageContentRef = useRef<HTMLDivElement>(null);
return (
<>
<OfflineBand userId="user-1" pageContentRef={pageContentRef} />
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
Seiteninhalt
</div>
</>
);
}
function renderBand() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<BandOnPage />
</QueryClientProvider>,
);
}
/** Opens the sheet from the rejected entries and discards the rejected one. */
async function discardRejectedEntry(nextSummary: { own: number; failed: number; foreign: number }) {
await userEvent.click(
await screen.findByRole("button", { name: "1 Eintrag abgelehnt – ansehen" }),
);
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
vi.mocked(summarizeQueue).mockResolvedValue(nextSummary);
vi.mocked(listQueue).mockResolvedValue(nextSummary.own > 0 ? [WAITING] : []);
await userEvent.click(
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
);
const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
return sheet;
}
describe("OfflineBand", () => {
beforeEach(() => {
vi.mocked(summarizeQueue).mockReset();
vi.mocked(listQueue).mockReset().mockResolvedValue([]);
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
vi.mocked(resumeSync).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("announces „Offline – 2 Einträge warten\" in a status region", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Offline – 2 Einträge warten"),
);
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
expect(summarizeQueue).toHaveBeenCalledWith("user-1");
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("says „1 Eintrag wartet\" for a single entry", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Offline – 1 Eintrag wartet"),
);
});
it("tells that input is kept while offline with an empty queue", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 });
renderBand();
expect(await screen.findByRole("status")).toHaveTextContent(
"Offline – Eingaben werden gespeichert und später gesendet",
);
});
it("keeps an empty status region online with an empty queue", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 3 });
renderBand();
await waitFor(() => expect(summarizeQueue).toHaveBeenCalled());
await act(async () => {});
// The live region must exist before its first message, or that message is not announced.
const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toBeEmptyDOMElement();
});
it("„Jetzt senden\" starts syncPendingEntries", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand();
const button = await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
// Online on mount the band already tries once by itself.
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(2));
expect(syncPendingEntries).toHaveBeenLastCalledWith("user-1");
});
it("its button is at least 44 px", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
renderBand();
expect(
await screen.findByRole("button", { name: "1 Eintrag wartet – Jetzt senden" }),
).toHaveClass("min-h-11");
expect(screen.getByRole("button", { name: "1 Eintrag abgelehnt – ansehen" })).toHaveClass(
"min-h-11",
);
});
it("opens the queue sheet from the rejected entries", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 2, foreign: 0 });
renderBand();
await userEvent.click(
await screen.findByRole("button", { name: "2 Einträge abgelehnt – ansehen" }),
);
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
expect(listQueue).toHaveBeenCalledWith("user-1");
});
it("closes the sheet once nothing is left and keeps focus in the band while it has content", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
renderBand();
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
// Offline, the band still says so - focus lands on that message.
await waitFor(() => expect(screen.getByRole("status")).toHaveFocus());
});
it("moves focus to the page content when the band has nothing left to show", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
renderBand();
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByTestId("page-content")).toHaveFocus());
expect(screen.getByRole("status")).toBeEmptyDOMElement();
});
it("keeps the sheet open with the waiting entries after the last rejected one is discarded", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
vi.mocked(listQueue).mockResolvedValue([REJECTED, WAITING]);
renderBand();
const sheet = await discardRejectedEntry({ own: 1, failed: 0, foreign: 0 });
await waitFor(() =>
expect(screen.queryByRole("button", { name: /abgelehnt – ansehen/ })).not.toBeInTheDocument(),
);
expect(within(sheet).getByText("Fütterung")).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
await userEvent.click(within(sheet).getByRole("button", { name: "Schließen" }));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
);
// The trigger is gone; focus goes to the band, which still shows the
// waiting entry (Base UI picks its first focusable element, the send button).
await waitFor(() =>
expect(screen.getByRole("status")).toContainElement(document.activeElement as HTMLElement),
);
expect(screen.getByRole("button", { name: "1 Eintrag wartet – Jetzt senden" })).toHaveFocus();
});
it("opens sync for the signed-in user before its first automatic sync", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(resumeSync).toHaveBeenCalledTimes(1);
expect(vi.mocked(resumeSync).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
});
it("sends by itself as soon as the connection is back", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderBand();
await screen.findByText("Offline – 1 Eintrag wartet");
expect(syncPendingEntries).not.toHaveBeenCalled();
setOnLine(true);
act(() => {
window.dispatchEvent(new Event("online"));
});
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
});
});
@@ -0,0 +1,171 @@
"use client";
import { RefObject, useEffect, useRef, useState } from "react";
import { AlertTriangle, CloudOff, Send } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useConnectivity } from "@/lib/offline/connectivity";
import { QueueSummary, summarizeQueue } from "@/lib/offline/db";
import { entriesRejected, entriesSynced, entriesWaiting } from "@/lib/offline/queue-texts";
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { PendingQueueSheet } from "./pending-queue-sheet";
const COUNT_INTERVAL_MS = 15_000;
const EMPTY_QUEUE: QueueSummary = { own: 0, failed: 0, foreign: 0 };
// Both auto-sync triggers can fire together; they share one sync run
// (syncPendingEntries), and one toast id keeps it to one message.
const SYNC_TOAST_ID = "offline-sync";
// Wraps on narrow phones instead of overflowing; never below 44 px.
const BAND_BUTTON_CLASS = "h-auto min-h-11 whitespace-normal text-left";
interface OfflineBandProps {
userId: string;
/** Focus target when the queue sheet closes and the band shows nothing. */
pageContentRef: RefObject<HTMLElement | null>;
}
/**
* One line under the header that says whether input is only stored on this
* device, how much still waits to be sent and what the server rejected.
* Sends by itself as soon as the connection is back.
*/
export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
const queryClient = useQueryClient();
const connectivity = useConnectivity();
const statusRef = useRef<HTMLDivElement>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const { data: queue = EMPTY_QUEUE } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "summary"],
queryFn: () => summarizeQueue(userId),
refetchInterval: COUNT_INTERVAL_MS,
// Reads this device's storage, never the network.
networkMode: "always",
});
const sync = useMutation({
mutationFn: () => syncPendingEntries(userId),
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced), { id: SYNC_TOAST_ID });
// New records on the server: every list and count may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () =>
toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht", {
id: SYNC_TOAST_ID,
}),
});
const { mutate: startSync } = sync;
// The band only exists for a signed-in user, and the auth context sets that
// user's access token before the user: the moment to reopen sync after a
// logout's cancelSync(). Declared first, so it runs before the sync below.
useEffect(() => {
resumeSync();
}, [userId]);
// Mount, and every switch back to online (a request reached the server again).
useEffect(() => {
if (connectivity === "online") {
startSync();
}
}, [connectivity, startSync, userId]);
// The browser's own `online` event: try even while the last request failed.
useEffect(() => {
const handleOnline = () => startSync();
window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline);
}, [startSync]);
// The sheet stays open while the user still has entries in it (waiting
// ones too, after the last rejected one was discarded), and closes once
// there is nothing left to show.
const hasRejected = queue.failed > 0;
if (sheetOpen && queue.own === 0 && !hasRejected) {
setSheetOpen(false);
}
const isOffline = connectivity === "offline";
const hasContent = isOffline || queue.own > 0 || hasRejected;
// Back to the trigger while it exists; otherwise to the band's message, or
// to the page content when the band shows nothing.
function sheetFinalFocus(): true | HTMLElement | null {
if (hasRejected) {
return true;
}
return hasContent ? statusRef.current : pageContentRef.current;
}
// The live region is always there, so its first message is announced too.
return (
<div
ref={statusRef}
role="status"
aria-live="polite"
tabIndex={-1}
className={cn(
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
hasContent &&
"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6",
hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"),
)}
>
{isOffline ? (
<p className="flex items-center gap-2 font-medium">
<CloudOff className="size-4 shrink-0" aria-hidden />
{queue.own > 0
? `Offline – ${entriesWaiting(queue.own)}`
: "Offline – Eingaben werden gespeichert und später gesendet"}
</p>
) : null}
{!isOffline && queue.own > 0 ? (
<Button
type="button"
variant="outline"
size="touch"
className={BAND_BUTTON_CLASS}
disabled={sync.isPending}
onClick={() => startSync()}
>
<Send aria-hidden />
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
</Button>
) : null}
<PendingQueueSheet
userId={userId}
foreignCount={queue.foreign}
open={sheetOpen}
onOpenChange={setSheetOpen}
finalFocus={sheetFinalFocus}
trigger={
hasRejected ? (
<SheetTrigger
render={
<Button
type="button"
variant="destructive"
size="touch"
className={BAND_BUTTON_CLASS}
/>
}
>
<AlertTriangle aria-hidden />
{`${entriesRejected(queue.failed)} – ansehen`}
</SheetTrigger>
) : null
}
/>
</div>
);
}
@@ -0,0 +1,226 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { discardQueueItem, listQueue, type QueueItem, type StoredPhoto } from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
import { PendingQueueSheet } from "./pending-queue-sheet";
vi.mock("@/lib/offline/db", () => ({
listQueue: vi.fn(),
discardQueueItem: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/photos/store")>()),
listPendingPhotos: vi.fn(),
removePhoto: vi.fn(),
resetPhotoError: vi.fn(),
}));
const REJECTED_PHOTO: StoredPhoto = {
id: "photo-1",
sessionId: "session-1",
ownerUserId: "user-1",
blob: new Blob(["jpeg"], { type: "image/jpeg" }),
fileName: "durchsicht-2026-10-13-1.jpg",
createdAt: "2026-10-13T08:16:00.000Z",
state: "ready",
inspectionId: "srv-1",
uploadIdempotencyKey: "upload-key-0000000001",
lastError: { status: 413, message: "Datei zu groß", at: "2026-10-13T09:00:00.000Z" },
};
const WAITING_PHOTO: StoredPhoto = {
...REJECTED_PHOTO,
id: "photo-2",
createdAt: "2026-10-13T08:17:00.000Z",
lastError: undefined,
};
const REJECTED: QueueItem = {
store: "pending-inspections",
id: 7,
label: "Durchsicht",
queuedAt: "2026-10-13T08:15:00.000Z",
lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" },
};
const WAITING: QueueItem = {
store: "pending-feedings",
id: 3,
label: "Fütterung",
queuedAt: "2026-10-13T08:20:00.000Z",
};
/** How the sheet names an entry's capture time (local time zone, with seconds). */
function shown(item: QueueItem): string {
return new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
}
function renderSheet(foreignCount = 0) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<PendingQueueSheet
userId="user-1"
foreignCount={foreignCount}
open
onOpenChange={vi.fn()}
trigger={null}
/>
</QueryClientProvider>,
);
}
describe("PendingQueueSheet", () => {
beforeAll(() => {
// jsdom has no object URLs; the photo thumbnails only need a string.
URL.createObjectURL = vi.fn(() => "blob:photo");
URL.revokeObjectURL = vi.fn();
});
beforeEach(() => {
vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]);
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
vi.mocked(removePhoto).mockReset().mockResolvedValue(undefined);
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
vi.mocked(syncPendingEntries)
.mockReset()
.mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
});
it("shows „+ 2 Fotos“ at a queued inspection and discards them with it", async () => {
vi.mocked(listQueue).mockResolvedValue([{ ...REJECTED, photoCount: 2 }]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText("+ 2 Fotos")).toBeInTheDocument();
await userEvent.click(
within(sheet).getByRole("button", { name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}` }),
);
const confirm = await screen.findByRole("dialog", {
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
});
expect(confirm).toHaveTextContent("Der Eintrag und seine 2 Fotos werden von diesem Gerät gelöscht");
});
it("lets a rejected photo be sent again", async () => {
vi.mocked(listQueue).mockResolvedValue([]);
vi.mocked(listPendingPhotos).mockResolvedValue([REJECTED_PHOTO, WAITING_PHOTO]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText("Abgelehnt: Datei zu groß")).toBeInTheDocument();
expect(within(sheet).getAllByText("Foto zur Durchsicht")).toHaveLength(2);
expect(listPendingPhotos).toHaveBeenCalledWith("user-1");
// Only the rejected photo needs the user; the waiting one is sent by itself.
const retry = within(sheet).getAllByRole("button", { name: /^Erneut senden: / });
expect(retry).toHaveLength(1);
expect(retry[0]).toHaveAccessibleName(
`Erneut senden: Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`,
);
await userEvent.click(retry[0]);
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
expect(resetPhotoError).toHaveBeenCalledWith("photo-1");
expect(vi.mocked(resetPhotoError).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
);
});
it("discards a photo after confirmation", async () => {
vi.mocked(listQueue).mockResolvedValue([]);
vi.mocked(listPendingPhotos).mockResolvedValue([REJECTED_PHOTO]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
const name = `Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`;
await userEvent.click(await within(sheet).findByRole("button", { name: `Verwerfen: ${name}` }));
const confirm = await screen.findByRole("dialog", { name: `${name} verwerfen?` });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(removePhoto).toHaveBeenCalledWith("photo-1"));
expect(within(sheet).queryByText("Von dir wartet nichts mehr.")).not.toBeInTheDocument();
});
it("lists a rejected entry with the server message and can discard it after confirmation", async () => {
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText(/Volk V-03 ist beendet\./)).toBeInTheDocument();
expect(within(sheet).getByText(/Abgelehnt/)).toBeInTheDocument();
expect(within(sheet).getByText("Fütterung")).toBeInTheDocument();
expect(listQueue).toHaveBeenCalledWith("user-1");
const discardRejected = within(sheet).getByRole("button", {
name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}`,
});
await userEvent.click(discardRejected);
expect(discardQueueItem).not.toHaveBeenCalled();
const confirm = await screen.findByRole("dialog", {
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
});
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
});
it("names every discard button after its entry, starting with the visible word", async () => {
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
const buttons = await within(sheet).findAllByRole("button", { name: /^Verwerfen: / });
expect(buttons.map((button) => button.getAttribute("aria-label"))).toEqual([
`Verwerfen: Durchsicht vom ${shown(REJECTED)}`,
`Verwerfen: Fütterung vom ${shown(WAITING)}`,
]);
buttons.forEach((button) => expect(button).toHaveTextContent(/^Verwerfen$/));
});
it("tells apart two entries of the same type from the same minute", async () => {
const at = (iso: string, id: number): QueueItem => ({
store: "pending-inspections",
id,
label: "Durchsicht",
queuedAt: iso,
});
vi.mocked(listQueue).mockResolvedValue([
at("2026-10-13T08:15:05.000Z", 1),
at("2026-10-13T08:15:40.000Z", 2),
at("2026-10-13T08:15:40.000Z", 3),
]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
const names = (await within(sheet).findAllByRole("button", { name: /^Verwerfen: / })).map(
(button) => button.getAttribute("aria-label"),
);
expect(new Set(names).size).toBe(3);
expect(names[2]).toMatch(/\(2\)$/);
await userEvent.click(within(sheet).getAllByRole("button", { name: /^Verwerfen: / })[2]);
expect(
await screen.findByRole("dialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }),
).toBeInTheDocument();
});
it.each([
[1, "1 Eintrag eines anderen Kontos wartet"],
[2, "2 Einträge eines anderen Kontos warten"],
])("shows %i entries of another account only as a count", async (count, text) => {
vi.mocked(listQueue).mockResolvedValue([]);
renderSheet(count);
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText(text)).toBeInTheDocument();
expect(within(sheet).queryByRole("button", { name: /Verwerfen/ })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,256 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { AlertTriangle, Camera, Clock, RotateCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NavSheet } from "@/components/nav/nav-sheet";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button } from "@/components/ui/button";
import { discardQueueItem, listQueue, type QueueError, type QueueItem } from "@/lib/offline/db";
import { entriesSynced, foreignEntriesWaiting, photosAttached } from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
interface PendingQueueSheetProps {
userId: string;
/** Entries of another account on this device - shown only as a count. */
foreignCount: number;
open: boolean;
onOpenChange: (open: boolean) => void;
/** The `SheetTrigger` that opens the sheet, or null when opened elsewhere. */
trigger: ReactNode;
/** Where focus goes on close; by default back to the trigger. */
finalFocus?: ComponentProps<typeof NavSheet>["finalFocus"];
}
interface Named {
label: string;
queuedAt: string;
}
const PHOTO_LABEL = "Foto";
function queuedAtLabel(queuedAt: string): string {
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
/**
* „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and
* dialog. Seconds tell apart entries of the same minute; a running number
* „(2)" those queued in the same second.
*/
function entryNames(items: readonly Named[]): string[] {
const seen = new Map<string, number>();
return items.map((item) => {
const time = new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
const name = `${item.label} vom ${time}`;
const occurrence = (seen.get(name) ?? 0) + 1;
seen.set(name, occurrence);
return occurrence === 1 ? name : `${name} (${occurrence})`;
});
}
/** A queued inspection takes its photos along when it is discarded. */
function discardDescription(item: QueueItem): string {
if (!item.photoCount) {
return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet.";
}
const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`;
return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`;
}
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({ lastError }: { lastError?: QueueError }) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Abgelehnt: {lastError.message}</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Wartet auf Senden</span>
</p>
);
}
/**
* What this device still has to send for the signed-in user: each entry with
* its state, a rejected one with the server's reason, and a way to discard it.
* Photos whose inspection is already on the server are listed on their own;
* a rejected one can be sent again.
*/
export function PendingQueueSheet({
userId,
foreignCount,
open,
onOpenChange,
trigger,
finalFocus,
}: PendingQueueSheetProps) {
const queryClient = useQueryClient();
const { data: items = [], isSuccess } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"],
queryFn: () => listQueue(userId),
enabled: open,
// Reads this device's storage, never the network.
networkMode: "always",
});
const { data: photos = [] } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"],
queryFn: () => listPendingPhotos(userId),
enabled: open,
networkMode: "always",
});
const discard = useMutation({
mutationFn: (item: QueueItem) => discardQueueItem(item),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Eintrag verworfen");
},
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => removePhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Foto verworfen");
},
onError: () => toast.error("Foto konnte nicht verworfen werden."),
});
// „Erneut senden": clear the rejection, then a normal sync run sends it.
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
return syncPendingEntries(userId);
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
// New files on the server: document lists may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
const names = entryNames(items);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
);
return (
<NavSheet
open={open}
onOpenChange={onOpenChange}
trigger={trigger}
title="Wartende Einträge"
finalFocus={finalFocus}
>
<div className="flex flex-col gap-3 px-4 pb-4">
{isSuccess && items.length === 0 && photos.length === 0 ? (
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
) : null}
<ul className="flex flex-col gap-2">
{items.map((item, index) => (
<li
key={`${item.store}-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Erfasst am {queuedAtLabel(item.queuedAt)}
</p>
{item.photoCount ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Camera className="size-4 shrink-0" aria-hidden />
{photosAttached(item.photoCount)}
</p>
) : null}
</div>
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${names[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${names[index]} verwerfen?`}
description={discardDescription(item)}
confirmLabel="Verwerfen"
onConfirm={() => discard.mutate(item)}
isPending={discard.isPending}
/>
</div>
<QueueStatus lastError={item.lastError} />
</li>
))}
{photos.map((photo, index) => (
<li
key={`photo-${photo.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<PhotoThumbnail
blob={photo.blob}
alt=""
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="font-medium">Foto zur Durchsicht</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(photo.createdAt)}
</p>
</div>
</div>
<QueueStatus lastError={photo.lastError} />
<div className="flex flex-wrap gap-2">
{photo.lastError ? (
<Button
type="button"
variant="outline"
size="touch"
aria-label={`Erneut senden: ${photoNames[index]}`}
disabled={retryPhoto.isPending}
onClick={() => retryPhoto.mutate(photo.id)}
>
<RotateCw aria-hidden />
Erneut senden
</Button>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${photoNames[index]} verwerfen?`}
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardPhoto.mutate(photo.id)}
isPending={discardPhoto.isPending}
/>
</div>
</li>
))}
</ul>
{foreignCount > 0 ? (
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
) : null}
</div>
</NavSheet>
);
}
@@ -0,0 +1,144 @@
import "fake-indexeddb/auto";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { addDraftPhoto, listSessionPhotos } from "@/lib/photos/store";
import { PhotoMetadataError, preparePhoto } from "@/lib/photos/reencode";
import { PhotoCaptureField } from "./photo-capture-field";
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/photos/reencode", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/photos/reencode")>()),
preparePhoto: vi.fn(),
}));
const CLEAN_PHOTO = new Blob(["clean jpeg"], { type: "image/jpeg" });
function cameraFile(): File {
return new File(["raw camera jpeg with gps"], "IMG_0001.jpg", { type: "image/jpeg" });
}
function cameraInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("no camera input");
return input;
}
let sessionId: string;
const onChange = vi.fn();
function renderField() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<PhotoCaptureField step={7} sessionId={sessionId} date="2026-10-13" onChange={onChange} />
</QueryClientProvider>,
);
}
async function storePhotos(count: number) {
for (let index = 0; index < count; index++) {
await addDraftPhoto({ sessionId, ownerUserId: "user-1", blob: CLEAN_PHOTO, date: "2026-10-13" });
}
}
describe("PhotoCaptureField", () => {
beforeAll(() => {
// jsdom has no object URLs; the thumbnails only need a string.
URL.createObjectURL = vi.fn(() => "blob:photo");
URL.revokeObjectURL = vi.fn();
});
beforeEach(() => {
// Every test works on its own capture session.
sessionId = crypto.randomUUID();
permissions = new Set(["documents:upload"]);
vi.mocked(preparePhoto).mockReset().mockResolvedValue(CLEAN_PHOTO);
onChange.mockReset();
});
it('input accepts image/* with capture="environment"', () => {
const { container } = renderField();
const input = cameraInput(container);
expect(input).toHaveAttribute("accept", "image/*");
expect(input).toHaveAttribute("capture", "environment");
expect(screen.getByRole("button", { name: "Foto aufnehmen" })).toBeInTheDocument();
});
it("is hidden without documents:upload", () => {
permissions = new Set();
const { container } = renderField();
expect(screen.queryByRole("button", { name: "Foto aufnehmen" })).not.toBeInTheDocument();
expect(screen.queryByText("Fotos")).not.toBeInTheDocument();
expect(container.querySelector('input[type="file"]')).toBeNull();
});
it("stores a taken photo right away, re-encoded, and shows it", async () => {
const { container } = renderField();
const file = cameraFile();
await userEvent.upload(cameraInput(container), file);
expect(await screen.findByRole("img", { name: "Foto 1" })).toBeInTheDocument();
expect(preparePhoto).toHaveBeenCalledWith(file);
const [stored] = await listSessionPhotos(sessionId);
expect(stored).toMatchObject({
ownerUserId: "user-1",
state: "draft",
fileName: "durchsicht-2026-10-13-1.jpg",
});
expect(onChange).toHaveBeenCalled();
expect(screen.getByRole("status")).toHaveTextContent("Foto 1 gespeichert");
});
it("blocks a 6th photo with a message", async () => {
await storePhotos(5);
const { container } = renderField();
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(5);
const openCamera = vi.spyOn(cameraInput(container), "click");
await userEvent.click(screen.getByRole("button", { name: "Foto aufnehmen" }));
expect(openCamera).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toHaveTextContent("Höchstens 5 Fotos je Durchsicht");
// Even a file that still arrives is not kept.
await userEvent.upload(cameraInput(container), cameraFile());
expect(preparePhoto).not.toHaveBeenCalled();
expect(await listSessionPhotos(sessionId)).toHaveLength(5);
});
it("removing a photo deletes it from the store", async () => {
await storePhotos(2);
renderField();
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
await userEvent.click(screen.getByRole("button", { name: "Entfernen: Foto 1" }));
const confirm = await screen.findByRole("dialog", { name: "Foto 1 entfernen?" });
await userEvent.click(within(confirm).getByRole("button", { name: "Entfernen" }));
await waitFor(async () => expect(await listSessionPhotos(sessionId)).toHaveLength(1));
await waitFor(() => expect(screen.getAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(1));
expect(onChange).toHaveBeenCalled();
});
it("keeps nothing when the location data could not be removed, and says so", async () => {
vi.mocked(preparePhoto).mockRejectedValue(new PhotoMetadataError());
const { container } = renderField();
await userEvent.upload(cameraInput(container), cameraFile());
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent("Foto wurde nicht gespeichert"),
);
expect(await listSessionPhotos(sessionId)).toEqual([]);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,229 @@
"use client";
import { ChangeEvent, useCallback, useRef, useState } from "react";
import { Camera } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { todayLocalIsoDate } from "@/lib/dates";
import { PhotoMetadataError, PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
import {
PHOTOS_PER_INSPECTION_MAX,
addDraftPhoto,
listSessionPhotos,
removePhoto,
} from "@/lib/photos/store";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { FormSection } from "@/components/form/form-section";
/** Prefix of the queries that read one form's photos from this device. */
export const PHOTO_SESSION_QUERY_KEY = ["photo-session"] as const;
const LIMIT_MESSAGE = `Höchstens ${PHOTOS_PER_INSPECTION_MAX} Fotos je Durchsicht. Entferne eines, um ein neues aufzunehmen.`;
interface Notice {
text: string;
isError: boolean;
}
const NO_NOTICE: Notice = { text: "", isError: false };
function captureError(error: unknown): Notice {
if (error instanceof PhotoTooLargeError) {
return { text: "Das Foto ist zu groß. Bitte noch einmal aufnehmen.", isError: true };
}
if (error instanceof PhotoMetadataError) {
return {
text: "Foto wurde nicht gespeichert: Die Standortdaten ließen sich nicht entfernen.",
isError: true,
};
}
return { text: "Foto konnte nicht gespeichert werden.", isError: true };
}
interface PhotoThumbnailProps {
blob: Blob;
alt: string;
className?: string;
}
/** A photo stored on this device, shown from an object URL. */
export function PhotoThumbnail({ blob, alt, className }: PhotoThumbnailProps) {
// The URL lives exactly as long as the mounted image (ref cleanup), also
// through StrictMode's unmount and remount.
const showBlob = useCallback(
(image: HTMLImageElement | null) => {
if (!image) return;
const url = URL.createObjectURL(blob);
image.src = url;
return () => URL.revokeObjectURL(url);
},
[blob],
);
// eslint-disable-next-line @next/next/no-img-element -- blob: object URL, next/image cannot handle this
return <img ref={showBlob} alt={alt} className={className} />;
}
interface PhotoCaptureFieldProps {
/** Position of the section in its form. */
step?: number;
/** The form's photo session (`photoSessionId` in its draft values). */
sessionId: string;
/** The inspection date (yyyy-mm-dd), part of each file name. */
date: string;
/** A photo was taken or removed: the form now has something to keep. */
onChange?: () => void;
}
/**
* „Foto aufnehmen" in an inspection form. Each photo is re-encoded without
* EXIF/GPS and stored on the device at once, so it is kept with the form
* draft and works without a network. Uploaded after the inspection is saved.
* Shown only to users who may upload documents.
*/
export function PhotoCaptureField(props: PhotoCaptureFieldProps) {
const { hasPermission } = useAuth();
if (!hasPermission("documents:upload")) {
return null;
}
return <PhotoCapture {...props} />;
}
function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProps) {
const queryClient = useQueryClient();
const ownerUserId = useCurrentUserId();
const inputRef = useRef<HTMLInputElement>(null);
const [notice, setNotice] = useState<Notice>(NO_NOTICE);
const queryKey = [...PHOTO_SESSION_QUERY_KEY, sessionId];
const { data: photos = [] } = useQuery({
queryKey,
queryFn: () => listSessionPhotos(sessionId),
// Reads this device's storage, never the network.
networkMode: "always",
});
const isFull = photos.length >= PHOTOS_PER_INSPECTION_MAX;
async function showStoredPhotos(): Promise<number> {
await queryClient.invalidateQueries({ queryKey });
return queryClient.getQueryData<unknown[]>(queryKey)?.length ?? 0;
}
const add = useMutation({
networkMode: "always",
mutationFn: async (file: File) => {
// Never store a photo without an owner: it would follow the next user.
if (!ownerUserId) {
throw new Error("No signed-in user");
}
const blob = await preparePhoto(file);
return addDraftPhoto({ sessionId, ownerUserId, blob, date: date || todayLocalIsoDate() });
},
onSuccess: async () => {
onChange?.();
const count = await showStoredPhotos();
setNotice({ text: `Foto ${count} gespeichert.`, isError: false });
},
onError: (error) => setNotice(captureError(error)),
});
const remove = useMutation({
networkMode: "always",
mutationFn: (id: string) => removePhoto(id),
onSuccess: async () => {
onChange?.();
await showStoredPhotos();
setNotice({ text: "Foto entfernt.", isError: false });
},
onError: () => setNotice({ text: "Foto konnte nicht entfernt werden.", isError: true }),
});
function openCamera() {
if (isFull) {
setNotice({ text: LIMIT_MESSAGE, isError: true });
return;
}
setNotice(NO_NOTICE);
inputRef.current?.click();
}
function handleFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
// Cleared, so the same picture can be chosen again after removing it.
event.target.value = "";
if (!file) return;
if (isFull) {
setNotice({ text: LIMIT_MESSAGE, isError: true });
return;
}
add.mutate(file);
}
return (
<FormSection
step={step}
title="Fotos"
description={`Bis zu ${PHOTOS_PER_INSPECTION_MAX} Fotos. Standortdaten werden vor dem Speichern entfernt.`}
>
{photos.length > 0 ? (
<ul className="grid grid-cols-3 gap-3" aria-label="Fotos dieser Durchsicht">
{photos.map((photo, index) => {
const name = `Foto ${index + 1}`;
return (
<li key={photo.id} className="flex flex-col gap-1">
<PhotoThumbnail
blob={photo.blob}
alt={name}
className="aspect-square w-full rounded-lg border border-border/60 object-cover"
/>
<ConfirmDialog
triggerLabel="Entfernen"
triggerAriaLabel={`Entfernen: ${name}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${name} entfernen?`}
description="Das Foto wird von diesem Gerät gelöscht."
confirmLabel="Entfernen"
onConfirm={() => remove.mutate(photo.id)}
isPending={remove.isPending}
/>
</li>
);
})}
</ul>
) : null}
<Button
type="button"
variant="outline"
size="touch"
className="w-fit"
disabled={add.isPending}
onClick={openCamera}
>
<Camera aria-hidden />
{add.isPending ? "Foto wird gespeichert…" : "Foto aufnehmen"}
</Button>
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
tabIndex={-1}
aria-hidden
onChange={handleFile}
/>
<p
role="status"
aria-live="polite"
className={cn(
"text-sm empty:hidden",
notice.isError ? "font-medium text-destructive" : "text-muted-foreground",
)}
>
{notice.text}
</p>
</FormSection>
);
}
@@ -0,0 +1,53 @@
"use client";
import { ArrowRight, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface RoundNextPanelProps {
/** Next colony to do, or null when the round is complete. */
next: { id: string; code: string } | null;
/** Colonies inspected in this round (for the closing message). */
doneCount: number;
/** Saved without a network: it syncs later. */
savedOffline: boolean;
onNext: () => void;
onEnd: () => void;
}
/** After a save in a round: one main step - on to the next colony. */
export function RoundNextPanel({ next, doneCount, savedOffline, onNext, onEnd }: RoundNextPanelProps) {
return (
<Card className="max-w-lg">
<CardContent className="flex flex-col gap-4">
<p role="status" className="flex items-center gap-2 text-lg font-medium">
<CheckCircle2 className="size-5 text-condition-good-foreground" aria-hidden />
Gespeichert
</p>
{savedOffline ? (
<p className="text-sm text-muted-foreground">
Kein Netzwerk – die Durchsicht wird synchronisiert, sobald du wieder Netz hast.
</p>
) : null}
{next ? (
<>
<Button type="button" size="touch" className="h-12 text-base" onClick={onNext}>
Weiter mit {next.code}
<ArrowRight aria-hidden />
</Button>
<Button type="button" size="touch" variant="outline" onClick={onEnd}>
Runde beenden
</Button>
</>
) : (
<>
<p>Runde abgeschlossen – alle {doneCount} Völker</p>
<Button type="button" size="touch" className="h-12 text-base" onClick={onEnd}>
Zum Stand
</Button>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,18 @@
/** Visible note that a value is a suggestion and not something the user entered. */
export function PrefillNote({ id, source }: { id: string; source: string | undefined }) {
if (!source) return null;
return (
<p id={`${id}-prefill`} className="text-xs font-medium text-muted-foreground">
aus letzter Behandlung mit {source}
</p>
);
}
/** The control's aria-describedby, extended by the prefill note when one is shown. */
export function describedByWith(
id: string,
describedBy: string | undefined,
source: string | undefined,
): string | undefined {
return [describedBy, source ? `${id}-prefill` : undefined].filter(Boolean).join(" ") || undefined;
}
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/form/field";
import { ChoiceGroup, ChoiceOption } from "@/components/form/choice-group";
import {
PERIOD_DAY_CHOICES,
PeriodChoice,
daysToMinutes,
formatDayCount,
parseDays,
periodChoiceOf,
} from "@/lib/varroa/period";
const PERIOD_OPTIONS: ChoiceOption<PeriodChoice>[] = [
...PERIOD_DAY_CHOICES.map((days) => ({
value: days,
label: days === 1 ? "1 Tag" : `${days} Tage`,
})),
{ value: "other" as const, label: "anders" },
];
interface VarroaPeriodFieldProps {
/** Stored minutes as text; this stays the only source of truth. */
minutes: string;
onChange: (minutes: string) => void;
hint?: string;
}
/**
* How long the board lay, picked in days. Still stores minutes: an old value
* that is not a whole choice is shown as "anders" and kept exactly until the
* user types a new number of days.
*/
export function VarroaPeriodField({ minutes, onChange, hint }: VarroaPeriodFieldProps) {
const [otherChosen, setOtherChosen] = useState(false);
const [daysText, setDaysText] = useState<string | null>(null);
const derived = periodChoiceOf(minutes);
const selected: PeriodChoice | null = otherChosen ? "other" : derived;
const storedMinutes = Number(minutes);
const showsStored = daysText === null && derived === "other" && Number.isFinite(storedMinutes);
const daysValue = daysText ?? (showsStored ? formatDayCount(storedMinutes) : "");
function choose(next: PeriodChoice | null) {
if (next === "other") {
setOtherChosen(true);
setDaysText(Number.isFinite(storedMinutes) && storedMinutes > 0 ? formatDayCount(storedMinutes) : "");
return;
}
setOtherChosen(false);
setDaysText(null);
onChange(next === null ? "" : String(daysToMinutes(next)));
}
const daysInvalid = daysValue.trim() !== "" && parseDays(daysValue) === null;
function typeDays(text: string) {
setDaysText(text);
const days = parseDays(text);
onChange(days === null ? "" : String(daysToMinutes(days)));
}
return (
<div className="flex flex-col gap-3">
<Field label="Zeitraum" group hint={hint}>
{({ id, labelId, describedBy }) => (
<ChoiceGroup
id={id}
labelledBy={labelId}
describedBy={describedBy}
options={PERIOD_OPTIONS}
value={selected}
onChange={choose}
clearValue={null}
className="grid-cols-3 sm:grid-cols-5"
/>
)}
</Field>
{selected === "other" ? (
<Field
label="Tage"
error={daysInvalid ? "Bitte eine Zahl größer als 0 eingeben, z. B. 1,5." : null}
hint={
showsStored
? `anders: ${formatDayCount(storedMinutes)} Tage (${storedMinutes} Minuten)`
: "z. B. 1,5 für anderthalb Tage"
}
>
{({ id, describedBy, invalid }) => (
<Input
id={id}
aria-describedby={describedBy}
aria-invalid={invalid}
type="text"
inputMode="decimal"
className="h-11"
value={daysValue}
onChange={(event) => typeDays(event.target.value)}
/>
)}
</Field>
) : null}
</div>
);
}
@@ -0,0 +1,55 @@
import { AlertTriangle, CheckCircle2, OctagonAlert } from "lucide-react";
import { RATING_TONE_CLASS } from "@/lib/forms/rating";
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
import { VarroaLight, varroaLight } from "@/lib/varroa/rate";
import { cn } from "@/lib/utils";
const LIGHTS: Record<VarroaLight, { Icon: typeof CheckCircle2; tone: string }> = {
green: { Icon: CheckCircle2, tone: RATING_TONE_CLASS[4] },
yellow: { Icon: AlertTriangle, tone: RATING_TONE_CLASS[3] },
red: { Icon: OctagonAlert, tone: RATING_TONE_CLASS[1] },
};
function lightText(light: VarroaLight, threshold: number): string {
if (light === "red") return "über der Schwelle, Behandlung prüfen";
if (light === "yellow") return "nahe an der Schwelle";
return `unter der Schwelle (${formatThreshold(threshold)}/Tag)`;
}
/**
* The live result of the count. The state is always spelled out and has an
* icon, so it never depends on the colour alone.
*/
export function VarroaRateLight({
rate,
threshold,
}: {
rate: number | null;
threshold: number | null;
}) {
const light = varroaLight(rate, threshold);
if (rate === null) {
return <div role="status" aria-live="polite" />;
}
const base = `${formatMitesPerDay(rate)} Milben/Tag`;
if (light === null || threshold === null) {
return (
<p role="status" aria-live="polite" className="text-sm font-medium">
{base}
</p>
);
}
const { Icon, tone } = LIGHTS[light];
return (
<p
role="status"
aria-live="polite"
className={cn("flex min-h-11 items-center gap-2 rounded-lg border px-3 text-sm font-medium", tone)}
>
<Icon aria-hidden className="size-5 shrink-0" />
<span>
{base} – {lightText(light, threshold)}
</span>
</p>
);
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { hasCombinedShopScopes } from "./scopes";
describe("hasCombinedShopScopes", () => {
it("is true for a key that may read and book", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
});
it("is false for single-scope keys", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read"] })).toBe(false);
expect(hasCombinedShopScopes({ scopes: ["shop-sync:write"] })).toBe(false);
});
});
@@ -0,0 +1,16 @@
import type { ApiKey } from "./types";
export type ApiKeyScope = "shop-sync:read" | "shop-sync:write";
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
"shop-sync:write": "Verkäufe und Rückbuchungen buchen (Wabenhain)",
};
/**
* Keys issued before reading and booking were split may still carry both;
* the server no longer creates such keys, the UI nudges towards two keys.
*/
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, "scopes">): boolean {
return apiKey.scopes.includes("shop-sync:read") && apiKey.scopes.includes("shop-sync:write");
}
+2 -4
View File
@@ -1,8 +1,6 @@
export type ApiKeyScope = "shop-sync:read";
import type { ApiKeyScope } from "./scopes";
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
};
export type { ApiKeyScope };
export interface ApiKey {
id: string;
@@ -0,0 +1,11 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { ColonyOverview } from "./field-types";
export function fetchColonyOverview(apiaryId: string): Promise<ColonyOverview> {
return apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
}
/** Query key of the apiary page's colony overview. */
export function colonyOverviewKey(apiaryId: string): readonly ["apiaries", string, "colony-overview"] {
return ["apiaries", apiaryId, "colony-overview"] as const;
}
@@ -0,0 +1,83 @@
import type { ApiaryStatus } from "./types";
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
import type { Inspection } from "@/lib/inspections/types";
import type { Queen } from "@/lib/queens/types";
import type { Task } from "@/lib/tasks/types";
// Mirrors ApiCore-Backend/src/apiaries/apiary-field.types.ts - keep both in step.
/** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow {
id: string;
code: string;
status: ColonyStatus;
strength: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null;
/** Unread WARNUNG notifications visible to the caller. */
openWarnings: number;
/** Unread KRITISCH notifications visible to the caller. */
openCritical: number;
}
export interface ColonyOverview {
apiaryId: string;
inspectionsVisible: boolean;
rows: ColonyOverviewRow[];
}
/**
* Everything the offline apiary mode needs. It deliberately carries no
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
* and no inspection userId. Sections the caller has no read permission for
* are null.
*/
export interface OfflinePackV1 {
version: 1;
generatedAt: string;
apiary: {
id: string;
name: string;
location: string | null;
status: ApiaryStatus;
};
overview: ColonyOverview;
colonies: Pick<
Colony,
| "id"
| "code"
| "apiaryId"
| "status"
| "colonyType"
| "hiveType"
| "strength"
| "broodStatus"
| "feedStatus"
| "healthStatus"
| "currentQueenId"
| "notes"
>[];
queens:
| Pick<
Queen,
"id" | "code" | "hatchYear" | "breed" | "markingColor" | "markingNumber" | "status"
>[]
| null;
inspections: Omit<Inspection, "userId">[] | null;
tasks:
| Pick<
Task,
| "id"
| "title"
| "description"
| "dueDate"
| "priority"
| "status"
| "relatedType"
| "relatedId"
>[]
| null;
}
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff. Show more