Merge main checkpoint c01a388 into feat/erp-inventar-export
Brings the ERP inventory export up to date with the platform backbone and field capture work. Conflicts in ARCHITECTURE.md, the API key service and the API key settings tab resolved by keeping both sides: - read and write keys stay separate; erp-inventory:read is read-only and cannot be combined with a write scope - the failed-attempt lockout counts in the shared ThrottlerHit table so it holds across API pods - the UI offers only scopes the user holds and creates one scope per key Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
53fab102f2
347 files changed
+26087
-2141
No files matched your search
@@ -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
|
||||
|
||||
Generated
+11
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Hexagon } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import { NotificationBell } from "@/components/notification-bell";
|
||||
import { OfflineSyncBadge } from "@/components/offline-sync-badge";
|
||||
import { OfflineBand } from "@/components/offline/offline-band";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,12 +20,14 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { QuickAddMenu } from "./quick-add-menu";
|
||||
import { QuickAddSheet } from "./quick-add-sheet";
|
||||
import { PageTransition } from "./page-transition";
|
||||
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, logout } = useAuth();
|
||||
// Focus lands here when the offline queue sheet closes and nothing else is left.
|
||||
const pageContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
@@ -32,6 +35,12 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
// Photos of form drafts nobody can restore any more (older than 3 days)
|
||||
// leave the device. Housekeeping only: if it fails, the next start tries again.
|
||||
useEffect(() => {
|
||||
void purgeStaleDraftPhotos(Date.now()).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
if (isLoading || !user) {
|
||||
return null;
|
||||
}
|
||||
@@ -57,7 +66,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
ApiCore
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<OfflineSyncBadge />
|
||||
<QuickAddSheet user={user} variant="header" />
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full">
|
||||
@@ -85,12 +94,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 pb-(--bottom-nav-height) sm:pb-(--safe-area-bottom)">
|
||||
<OfflineBand userId={user.id} pageContentRef={pageContentRef} />
|
||||
<div
|
||||
ref={pageContentRef}
|
||||
tabIndex={-1}
|
||||
className="flex-1 pb-(--bottom-nav-height) outline-none sm:pb-(--safe-area-bottom)"
|
||||
>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</div>
|
||||
</div>
|
||||
<BottomNav />
|
||||
<QuickAddMenu />
|
||||
<BottomNav user={user} />
|
||||
<QuickAddSheet user={user} variant="fab" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MoreSheet } from "./more-sheet";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/dashboard",
|
||||
}));
|
||||
|
||||
describe("MoreSheet", () => {
|
||||
it('„Mehr" opens a sheet listing Honigchargen with 56 px rows', async () => {
|
||||
render(<MoreSheet user={{ permissions: [] }} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mehr" }));
|
||||
|
||||
const link = await screen.findByRole("link", { name: "Honigchargen" });
|
||||
expect(link).toHaveAttribute("href", "/honey-batches");
|
||||
expect(link).toHaveClass("min-h-14");
|
||||
});
|
||||
|
||||
it("lists only what the user may see", async () => {
|
||||
render(<MoreSheet user={{ permissions: [] }} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mehr" }));
|
||||
|
||||
await screen.findByRole("link", { name: "Honigchargen" });
|
||||
expect(screen.queryByRole("link", { name: "Audit-Log" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu } from "lucide-react";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||
import { BOTTOM_NAV_HREFS, isNavActive, visibleNavGroups } from "./nav-config";
|
||||
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
|
||||
|
||||
/**
|
||||
* The fifth bottom-nav entry: opens every area the user may see, grouped like
|
||||
* the desktop sidebar. Rows are 56px high for gloved or hurried thumbs.
|
||||
*/
|
||||
export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const groups = visibleNavGroups(user);
|
||||
|
||||
// „Mehr" is the current place when the open page is not one of the four
|
||||
// favourites next to it.
|
||||
const isMoreActive =
|
||||
!BOTTOM_NAV_HREFS.some((href) => isNavActive(pathname, href)) &&
|
||||
groups.some((group) =>
|
||||
group.items.some((item) => isNavActive(pathname, item.href)),
|
||||
);
|
||||
|
||||
return (
|
||||
<NavSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Mehr"
|
||||
trigger={
|
||||
<SheetTrigger
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
|
||||
isMoreActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Menu className="size-5" aria-hidden="true" />
|
||||
Mehr
|
||||
</SheetTrigger>
|
||||
}
|
||||
>
|
||||
<nav aria-label="Alle Bereiche" className="flex flex-col gap-4 px-2 pb-4">
|
||||
{groups.map((group, index) => (
|
||||
<div key={group.label ?? `group-${index}`} className="flex flex-col">
|
||||
{group.label ? (
|
||||
<p className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted-foreground/80 uppercase">
|
||||
{group.label}
|
||||
</p>
|
||||
) : null}
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isNavActive(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
NAV_SHEET_ROW_CLASS,
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-foreground hover:bg-accent/70",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 shrink-0" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</NavSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADMIN_GROUP,
|
||||
BOTTOM_NAV_HREFS,
|
||||
NAV_GROUPS,
|
||||
visibleNavGroups,
|
||||
} from "./nav-config";
|
||||
|
||||
const ALL_ITEMS = [...NAV_GROUPS, ADMIN_GROUP].flatMap((group) => group.items);
|
||||
const ALL_PERMISSIONS = ALL_ITEMS.flatMap((item) =>
|
||||
item.permission === undefined ? [] : [item.permission].flat(),
|
||||
);
|
||||
|
||||
function hrefs(permissions: string[]): string[] {
|
||||
return visibleNavGroups({ permissions })
|
||||
.flatMap((group) => group.items)
|
||||
.map((item) => item.href);
|
||||
}
|
||||
|
||||
describe("nav-config", () => {
|
||||
it('every sidebar area is reachable on a phone via bottom nav or „Mehr"', () => {
|
||||
const sidebar = hrefs(ALL_PERMISSIONS);
|
||||
// „Mehr" lists every visible group, the bottom nav the four favourites.
|
||||
const phone = new Set([...BOTTOM_NAV_HREFS, ...sidebar]);
|
||||
|
||||
expect(sidebar.length).toBe(ALL_ITEMS.length);
|
||||
expect(sidebar.every((href) => phone.has(href))).toBe(true);
|
||||
expect(sidebar).toContain("/honey-batches");
|
||||
});
|
||||
|
||||
it("keeps every bottom nav entry in the nav config", () => {
|
||||
const known = ALL_ITEMS.map((item) => item.href);
|
||||
expect(BOTTOM_NAV_HREFS.every((href) => known.includes(href))).toBe(true);
|
||||
});
|
||||
|
||||
it("admin entries need their permission", () => {
|
||||
expect(hrefs([])).not.toContain("/settings/audit-log");
|
||||
expect(hrefs([])).not.toContain("/settings/backup");
|
||||
expect(visibleNavGroups({ permissions: [] }).map((g) => g.label)).not.toContain(
|
||||
"Verwaltung",
|
||||
);
|
||||
|
||||
const admin = visibleNavGroups({ permissions: ["audit:read"] }).find(
|
||||
(group) => group.label === "Verwaltung",
|
||||
);
|
||||
expect(admin?.items.map((item) => item.href)).toEqual(["/settings/audit-log"]);
|
||||
});
|
||||
|
||||
it("hides items the user may not see", () => {
|
||||
expect(hrefs([])).not.toContain("/winter");
|
||||
expect(hrefs(["winter-checks:read"])).toContain("/winter");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
Crown,
|
||||
Database,
|
||||
Droplet,
|
||||
Factory,
|
||||
FlaskConical,
|
||||
History,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
MapPin,
|
||||
Package,
|
||||
Scissors,
|
||||
Search,
|
||||
ShoppingBag,
|
||||
Snowflake,
|
||||
Users,
|
||||
Warehouse,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||
|
||||
/**
|
||||
* The single source of truth for every area of the app: the desktop sidebar,
|
||||
* the phone bottom nav and its „Mehr" sheet all read from here, so an area
|
||||
* can never be reachable on one screen size and missing on another.
|
||||
*/
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
/** Short name for the cramped bottom nav. */
|
||||
shortLabel?: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
// Any one of these grants visibility - used for hub pages that fold
|
||||
// together several previously-separate settings pages, each gated by its
|
||||
// own permission.
|
||||
permission?: string | string[];
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string | null;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: null,
|
||||
items: [
|
||||
{
|
||||
label: "Dashboard",
|
||||
shortLabel: "Start",
|
||||
href: "/dashboard",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{ label: "Suche", href: "/search", icon: Search },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Betrieb",
|
||||
items: [
|
||||
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
|
||||
{ label: "Völker", href: "/colonies", icon: Layers },
|
||||
{ label: "Königinnen", href: "/queens", icon: Crown },
|
||||
{
|
||||
label: "Überwinterung",
|
||||
href: "/winter",
|
||||
icon: Snowflake,
|
||||
permission: "winter-checks:read",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Arbeit",
|
||||
items: [
|
||||
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
|
||||
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Ernte & Lager",
|
||||
items: [
|
||||
{ label: "Ernten", href: "/harvests", icon: Scissors },
|
||||
{ label: "Schleuderungen", href: "/extractions", icon: FlaskConical },
|
||||
{ label: "Honigchargen", href: "/honey-batches", icon: Droplet },
|
||||
{ label: "Abfüllungen", href: "/bottling", icon: Package },
|
||||
{
|
||||
label: "Rohstoffe",
|
||||
href: "/raw-materials",
|
||||
icon: Boxes,
|
||||
permission: "raw-material:read",
|
||||
},
|
||||
{
|
||||
label: "Herstellungschargen",
|
||||
href: "/production-batches",
|
||||
icon: Factory,
|
||||
permission: "production-batch:read",
|
||||
},
|
||||
{ label: "Produkte", href: "/products", icon: ShoppingBag },
|
||||
{ label: "Lager", href: "/storage", icon: Warehouse },
|
||||
{ label: "Inventar", href: "/equipment", icon: Wrench },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Auswertung",
|
||||
items: [{ label: "Statistik", href: "/statistics", icon: BarChart3 }],
|
||||
},
|
||||
];
|
||||
|
||||
export const ADMIN_GROUP: NavGroup = {
|
||||
label: "Verwaltung",
|
||||
items: [
|
||||
{
|
||||
label: "Benutzer & Rollen",
|
||||
href: "/settings/users-roles",
|
||||
icon: Users,
|
||||
permission: ["users:read", "roles:read"],
|
||||
},
|
||||
{
|
||||
label: "Automatisierung",
|
||||
href: "/settings/automation",
|
||||
icon: Zap,
|
||||
permission: [
|
||||
"webhooks:read",
|
||||
"automation:read",
|
||||
"task-templates:read",
|
||||
"label-templates:read",
|
||||
"site-settings:read",
|
||||
"calendar:configure",
|
||||
"api-keys:manage",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Backup",
|
||||
href: "/settings/backup",
|
||||
icon: Database,
|
||||
permission: ["backup:export", "backup:import", "import:execute"],
|
||||
},
|
||||
{
|
||||
label: "Audit-Log",
|
||||
href: "/settings/audit-log",
|
||||
icon: History,
|
||||
permission: "audit:read",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** The four favourites on the phone; everything else sits behind „Mehr". */
|
||||
export const BOTTOM_NAV_HREFS = [
|
||||
"/dashboard",
|
||||
"/colonies",
|
||||
"/tasks",
|
||||
"/search",
|
||||
] as const;
|
||||
|
||||
function isVisible(
|
||||
user: Pick<AuthUser, "permissions">,
|
||||
permission: NavItem["permission"],
|
||||
): boolean {
|
||||
if (!permission) {
|
||||
return true;
|
||||
}
|
||||
const required = Array.isArray(permission) ? permission : [permission];
|
||||
return required.some((key) => user.permissions.includes(key));
|
||||
}
|
||||
|
||||
/** All groups the user may see, without empty ones; admin group last. */
|
||||
export function visibleNavGroups(user: Pick<AuthUser, "permissions">): NavGroup[] {
|
||||
return [...NAV_GROUPS, ADMIN_GROUP]
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => isVisible(user, item.permission)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
/** The bottom nav entries, resolved from the config in their fixed order. */
|
||||
export function bottomNavItems(): NavItem[] {
|
||||
const all = NAV_GROUPS.flatMap((group) => group.items);
|
||||
return BOTTOM_NAV_HREFS.flatMap((href) => all.filter((item) => item.href === href));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a nav entry is the current area. Matches on a path boundary, so
|
||||
* `/queens` does not light up for `/queens-archive`.
|
||||
*/
|
||||
export function isNavActive(pathname: string, href: string): boolean {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
@@ -63,3 +63,50 @@ describe("NewProductionBatchPage – consumed raw materials", () => {
|
||||
expect(middle).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NewProductionBatchPage – leaving", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchRawMaterialLots).mockResolvedValue([LOT] as never);
|
||||
vi.mocked(fetchProducts).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewProductionBatchPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("lets an untouched form go back without asking", () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByRole("link", { name: /Zu den Herstellungschargen/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Formular verlassen?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks before leaving once a raw material row was added", async () => {
|
||||
renderPage();
|
||||
const addRow = await screen.findByRole("button", { name: "+ Rohstoff hinzufügen" });
|
||||
await vi.waitFor(() => expect(addRow).toBeEnabled());
|
||||
|
||||
await userEvent.click(addRow);
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zu den Herstellungschargen/ }));
|
||||
|
||||
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still asks after removing one of two rows", async () => {
|
||||
renderPage();
|
||||
const addRow = await screen.findByRole("button", { name: "+ Rohstoff hinzufügen" });
|
||||
await vi.waitFor(() => expect(addRow).toBeEnabled());
|
||||
await userEvent.click(addRow);
|
||||
await userEvent.click(addRow);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Rohstoff 1 entfernen" }));
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zu den Herstellungschargen/ }));
|
||||
|
||||
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RAW_MATERIAL_KIND_LABELS,
|
||||
formatGrams,
|
||||
} from "@/lib/raw-materials/labels";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,7 +35,8 @@ export default function NewProductionBatchPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [productId, setProductId] = useState("");
|
||||
const [lotNumber, setLotNumber] = useState("");
|
||||
const [date, setDate] = useState(todayLocalIsoDate());
|
||||
const [initialDate] = useState(todayLocalIsoDate);
|
||||
const [date, setDate] = useState(initialDate);
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [bestBeforeDate, setBestBeforeDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
@@ -60,6 +62,19 @@ export default function NewProductionBatchPage() {
|
||||
const removeInput = (key: string) =>
|
||||
setInputs((current) => current.filter((row) => row.key !== key));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// No autosaved draft here, so leaving asks whenever the entry differs from
|
||||
// the untouched form. Derived from the values: custom selects and the row
|
||||
// buttons do not fire a form change event. `touched` only supplements.
|
||||
const [touched, setTouched] = useState(false);
|
||||
const dirty =
|
||||
touched ||
|
||||
productId !== "" ||
|
||||
lotNumber !== "" ||
|
||||
date !== initialDate ||
|
||||
quantity !== "" ||
|
||||
bestBeforeDate !== "" ||
|
||||
notes !== "" ||
|
||||
inputs.length > 0;
|
||||
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ["products", { status: "ACTIVE" }],
|
||||
@@ -111,6 +126,11 @@ export default function NewProductionBatchPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<FormBackLink
|
||||
href="/production-batches"
|
||||
label="Zu den Herstellungschargen"
|
||||
dirty={dirty}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Factory}
|
||||
title="Neue Herstellungscharge"
|
||||
@@ -120,6 +140,7 @@ export default function NewProductionBatchPage() {
|
||||
<CardContent>
|
||||
<form
|
||||
className="flex max-w-xl flex-col gap-4"
|
||||
onChange={() => setTouched(true)}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
@@ -85,6 +86,12 @@ export default function NewProductPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<FormBackLink
|
||||
href="/products"
|
||||
label="Zu den Produkten"
|
||||
dirty={dirty}
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={ShoppingBag}
|
||||
title="Neues Produkt"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
@@ -113,6 +114,12 @@ export default function NewQueenPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<FormBackLink
|
||||
href="/queens"
|
||||
label="Zu den Königinnen"
|
||||
dirty={dirty}
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Crown}
|
||||
title="Neue Königin"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Bug,
|
||||
ClipboardCheck,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
Scissors,
|
||||
Sprout,
|
||||
Syringe,
|
||||
Utensils,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface QuickAction {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
/** Creating needs this permission; actions without it are not offered. */
|
||||
permission: string;
|
||||
}
|
||||
|
||||
export const QUICK_ACTIONS: readonly QuickAction[] = [
|
||||
{
|
||||
label: "Durchsicht",
|
||||
href: "/inspections/new",
|
||||
icon: ClipboardCheck,
|
||||
permission: "inspections:create",
|
||||
},
|
||||
{
|
||||
label: "Varroa-Messung",
|
||||
href: "/varroa/new",
|
||||
icon: Bug,
|
||||
permission: "varroa:create",
|
||||
},
|
||||
{
|
||||
label: "Schwarmkontrolle",
|
||||
href: "/swarm-control/new",
|
||||
icon: Sprout,
|
||||
permission: "swarm-control:create",
|
||||
},
|
||||
{
|
||||
label: "Behandlung",
|
||||
href: "/treatments/new",
|
||||
icon: Syringe,
|
||||
permission: "treatments:create",
|
||||
},
|
||||
{
|
||||
label: "Fütterung",
|
||||
href: "/feedings/new",
|
||||
icon: Utensils,
|
||||
permission: "feedings:create",
|
||||
},
|
||||
{
|
||||
label: "Aufgabe",
|
||||
href: "/tasks/new",
|
||||
icon: ListTodo,
|
||||
permission: "tasks:create",
|
||||
},
|
||||
{
|
||||
label: "Ernte",
|
||||
href: "/harvests/new",
|
||||
icon: Scissors,
|
||||
permission: "harvests:create",
|
||||
},
|
||||
];
|
||||
@@ -1,38 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QuickAddMenu } from "./quick-add-menu";
|
||||
|
||||
let mockPathname = "/dashboard";
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
describe("QuickAddMenu", () => {
|
||||
beforeEach(() => {
|
||||
mockPathname = "/dashboard";
|
||||
});
|
||||
|
||||
it.each(["/dashboard", "/colonies", "/colonies/abc", "/news", "/tasks/renewal"])(
|
||||
"offers the quick-add button on %s",
|
||||
(pathname) => {
|
||||
mockPathname = pathname;
|
||||
|
||||
render(<QuickAddMenu />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Neu" })).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"/inspections/new",
|
||||
"/settings/label-templates/new",
|
||||
"/colonies/abc/edit",
|
||||
"/colonies/new/",
|
||||
])("hides the quick-add button on the form route %s so it cannot cover the save bar", (pathname) => {
|
||||
mockPathname = pathname;
|
||||
|
||||
render(<QuickAddMenu />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ label: "Durchsicht", href: "/inspections/new" },
|
||||
{ label: "Varroa-Messung", href: "/varroa/new" },
|
||||
{ label: "Schwarmkontrolle", href: "/swarm-control/new" },
|
||||
{ label: "Behandlung", href: "/treatments/new" },
|
||||
{ label: "Fütterung", href: "/feedings/new" },
|
||||
{ label: "Aufgabe", href: "/tasks/new" },
|
||||
{ label: "Ernte", href: "/harvests/new" },
|
||||
];
|
||||
|
||||
// Create and edit forms end in a save bar (StickySubmit) right where this
|
||||
// button floats. Deciding by route rather than letting StickySubmit register
|
||||
// itself in a context is known on the first render (no flash, no layout
|
||||
// shift), needs no client state in StickySubmit and also covers form routes
|
||||
// without a sticky bar. Mid-form, jumping to another new form is no quick
|
||||
// action anyway.
|
||||
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
|
||||
|
||||
export function QuickAddMenu() {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (FORM_ROUTE.test(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon-lg"
|
||||
// size-12: 48px touch target for gloved hands (icon-lg is 36px).
|
||||
className="fixed right-4 bottom-[calc(var(--bottom-nav-height)+--spacing(4))] z-40 size-12 rounded-full shadow-lg sm:hidden"
|
||||
aria-label="Neu"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus className="size-6" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top">
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
<DropdownMenuItem key={action.href} render={<Link href={action.href} />}>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QUICK_ACTIONS } from "./quick-actions";
|
||||
import { QuickAddSheet } from "./quick-add-sheet";
|
||||
|
||||
let mockPathname = "/dashboard";
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
const ALL = QUICK_ACTIONS.map((action) => action.permission);
|
||||
|
||||
describe("QuickAddSheet", () => {
|
||||
beforeEach(() => {
|
||||
mockPathname = "/dashboard";
|
||||
});
|
||||
|
||||
it("lists actions with icons as links", async () => {
|
||||
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
|
||||
|
||||
const link = await screen.findByRole("link", { name: "Durchsicht" });
|
||||
expect(link).toHaveAttribute("href", "/inspections/new");
|
||||
expect(link).toHaveClass("min-h-14");
|
||||
expect(link.querySelector("svg.size-6")).not.toBeNull();
|
||||
expect(screen.getAllByRole("link")).toHaveLength(QUICK_ACTIONS.length);
|
||||
});
|
||||
|
||||
it("hides Behandlung without treatments:create", async () => {
|
||||
render(
|
||||
<QuickAddSheet
|
||||
user={{ permissions: ALL.filter((p) => p !== "treatments:create") }}
|
||||
variant="fab"
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neu" }));
|
||||
|
||||
await screen.findByRole("link", { name: "Durchsicht" });
|
||||
expect(screen.queryByRole("link", { name: "Behandlung" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when no action is allowed", () => {
|
||||
render(<QuickAddSheet user={{ permissions: [] }} variant="fab" />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/inspections/new",
|
||||
"/settings/label-templates/new",
|
||||
"/colonies/abc/edit",
|
||||
"/colonies/new/",
|
||||
])("is not rendered on the form route %s", (pathname) => {
|
||||
mockPathname = pathname;
|
||||
|
||||
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["/dashboard", "/colonies/abc", "/news", "/tasks/renewal"])(
|
||||
"offers the button on %s",
|
||||
(pathname) => {
|
||||
mockPathname = pathname;
|
||||
|
||||
render(<QuickAddSheet user={{ permissions: ALL }} variant="fab" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Neu" })).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("offers a header button on desktop", () => {
|
||||
render(<QuickAddSheet user={{ permissions: ALL }} variant="header" />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /Neu/ });
|
||||
expect(button).toHaveClass("sm:inline-flex");
|
||||
expect(button).toHaveClass("hidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
|
||||
import { QUICK_ACTIONS } from "./quick-actions";
|
||||
|
||||
// Create and edit forms end in a save bar (StickySubmit) right where the
|
||||
// phone button floats. Deciding by route rather than letting StickySubmit
|
||||
// register itself in a context is known on the first render (no flash, no
|
||||
// layout shift), needs no client state in StickySubmit and also covers form
|
||||
// routes without a sticky bar. Mid-form, jumping to another new form is no
|
||||
// quick action anyway - and the header button hides with it for consistency.
|
||||
const FORM_ROUTE = /\/(new|edit)(\/|$)/;
|
||||
|
||||
interface QuickAddSheetProps {
|
||||
user: Pick<AuthUser, "permissions">;
|
||||
/** "fab" floats above the phone bottom nav, "header" sits in the desktop header. */
|
||||
variant: "fab" | "header";
|
||||
}
|
||||
|
||||
/** „+ Neu": the same icon sheet on the phone (FAB) and on the desktop (header). */
|
||||
export function QuickAddSheet({ user, variant }: QuickAddSheetProps) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = QUICK_ACTIONS.filter((action) =>
|
||||
user.permissions.includes(action.permission),
|
||||
);
|
||||
|
||||
if (FORM_ROUTE.test(pathname) || actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Neu erfassen"
|
||||
className="sm:mx-auto sm:max-w-md"
|
||||
trigger={
|
||||
variant === "fab" ? (
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon-lg"
|
||||
// size-12: 48px touch target for gloved hands (icon-lg is 36px).
|
||||
className="fixed right-4 bottom-[calc(var(--bottom-nav-height)+--spacing(4))] z-40 size-12 rounded-full shadow-lg sm:hidden"
|
||||
aria-label="Neu"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus className="size-6" aria-hidden="true" />
|
||||
</SheetTrigger>
|
||||
) : (
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button className="hidden min-h-11 gap-1.5 sm:inline-flex" />
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
Neu
|
||||
</SheetTrigger>
|
||||
)
|
||||
}
|
||||
>
|
||||
<nav aria-label="Neu erfassen" className="flex flex-col px-2 pb-4">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`${NAV_SHEET_ROW_CLASS} text-foreground hover:bg-accent/70`}
|
||||
>
|
||||
<Icon
|
||||
className="size-6 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{action.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</NavSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import NewRawMaterialPage from "./page";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
|
||||
vi.mock("@/lib/raw-materials/api", () => ({ createRawMaterialLot: vi.fn() }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewRawMaterialPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("NewRawMaterialPage – leaving", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchApiaries).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("lets an untouched form go back without asking", async () => {
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
|
||||
|
||||
expect(screen.queryByText("Formular verlassen?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks before leaving after only a select value was changed", async () => {
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "Art" }));
|
||||
const options = await screen.findAllByRole("option");
|
||||
await userEvent.click(options[options.length - 1]);
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
|
||||
|
||||
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks before leaving after typing", async () => {
|
||||
renderPage();
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Menge (g)"), "100");
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zu den Rohstoffen/ }));
|
||||
|
||||
expect(await screen.findByText("Formular verlassen?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { createRawMaterialLot } from "@/lib/raw-materials/api";
|
||||
import { RAW_MATERIAL_KIND_LABELS } from "@/lib/raw-materials/labels";
|
||||
import { RawMaterialKind } from "@/lib/raw-materials/types";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -25,12 +26,25 @@ export default function NewRawMaterialPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [kind, setKind] = useState<RawMaterialKind>("BEESWAX");
|
||||
const [lotNumber, setLotNumber] = useState("");
|
||||
const [date, setDate] = useState(todayLocalIsoDate);
|
||||
const [initialDate] = useState(todayLocalIsoDate);
|
||||
const [date, setDate] = useState(initialDate);
|
||||
const [quantity, setQuantity] = useState("");
|
||||
// "" = "Kein Stand".
|
||||
const [apiaryId, setApiaryId] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// No autosaved draft here, so leaving asks whenever the entry differs from
|
||||
// the untouched form. Derived from the values: custom selects do not fire
|
||||
// a form change event. `touched` only supplements.
|
||||
const [touched, setTouched] = useState(false);
|
||||
const dirty =
|
||||
touched ||
|
||||
kind !== "BEESWAX" ||
|
||||
lotNumber !== "" ||
|
||||
date !== initialDate ||
|
||||
quantity !== "" ||
|
||||
apiaryId !== "" ||
|
||||
notes !== "";
|
||||
|
||||
const { data: apiaries } = useQuery({
|
||||
queryKey: ["apiaries"],
|
||||
@@ -69,6 +83,11 @@ export default function NewRawMaterialPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<FormBackLink
|
||||
href="/raw-materials"
|
||||
label="Zu den Rohstoffen"
|
||||
dirty={dirty}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Boxes}
|
||||
title="Neuer Rohstoff"
|
||||
@@ -78,6 +97,7 @@ export default function NewRawMaterialPage() {
|
||||
<CardContent>
|
||||
<form
|
||||
className="flex max-w-xl flex-col gap-4"
|
||||
onChange={() => setTouched(true)}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -2,10 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
import { ApiKeysTab } from "./api-keys-tab";
|
||||
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
|
||||
import type { ApiKey } from "@/lib/api-keys/types";
|
||||
import type { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
|
||||
|
||||
// Rechte des angemeldeten Nutzers; je Test anpassbar.
|
||||
let ownPermissions: string[] = [];
|
||||
@@ -13,24 +12,22 @@ vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (permission: string) => ownPermissions.includes(permission) }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/api-keys/api", () => ({
|
||||
createApiKey: vi.fn(),
|
||||
fetchApiKeys: vi.fn(),
|
||||
createApiKey: vi.fn(),
|
||||
revokeApiKey: vi.fn(),
|
||||
updateApiKeyAllowedIps: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithClient(ui: ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
const HINT = /Dieser Schlüssel darf lesen und buchen/;
|
||||
|
||||
function key(overrides: Partial<ApiKey>): ApiKey {
|
||||
function makeKey(id: string, scopes: ApiKeyScope[], overrides: Partial<ApiKey> = {}): ApiKey {
|
||||
return {
|
||||
id: "k1",
|
||||
name: "Schlüssel",
|
||||
prefix: "ack_abcd1234",
|
||||
scopes: ["shop-sync:read"],
|
||||
id,
|
||||
name: `Schlüssel ${id}`,
|
||||
prefix: `ack_${id}`,
|
||||
scopes,
|
||||
createdByUserId: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
@@ -42,79 +39,165 @@ function key(overrides: Partial<ApiKey>): ApiKey {
|
||||
};
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ApiKeysTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiKeysTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ownPermissions = ["api-keys:manage", "shop-sync:read", "shop-sync:write", "erp-inventory:read"];
|
||||
vi.mocked(fetchApiKeys).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(createApiKey).mockReset();
|
||||
vi.mocked(revokeApiKey).mockReset();
|
||||
});
|
||||
|
||||
it("legt einen Schlüssel nur mit den angekreuzten Berechtigungen an", async () => {
|
||||
vi.mocked(createApiKey).mockResolvedValueOnce({
|
||||
apiKey: key({ name: "ERPNext", scopes: ["erp-inventory:read"] }),
|
||||
// Synthetischer Wert, kein echter Schlüssel.
|
||||
token: `ack_abcd1234_${"A".repeat(43)}`,
|
||||
});
|
||||
renderWithClient(<ApiKeysTab />);
|
||||
describe("separation hint", () => {
|
||||
it("shows the separation hint only for a key with both scopes", async () => {
|
||||
vi.mocked(fetchApiKeys).mockResolvedValue([
|
||||
makeKey("both", ["shop-sync:read", "shop-sync:write"]),
|
||||
makeKey("read", ["shop-sync:read"]),
|
||||
]);
|
||||
renderTab();
|
||||
|
||||
const name = screen.getByLabelText("Wofür ist der Schlüssel?");
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, "ERPNext");
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ }));
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
|
||||
|
||||
expect(vi.mocked(createApiKey).mock.calls[0][0]).toMatchObject({
|
||||
name: "ERPNext",
|
||||
scopes: ["erp-inventory:read"],
|
||||
const hints = await screen.findAllByRole("status");
|
||||
expect(hints).toHaveLength(1);
|
||||
expect(hints[0]).toHaveTextContent(HINT);
|
||||
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
|
||||
});
|
||||
|
||||
it("also marks a key that reads the ERP mirror and books", async () => {
|
||||
vi.mocked(fetchApiKeys).mockResolvedValue([
|
||||
makeKey("mixed", ["erp-inventory:read", "shop-sync:write"]),
|
||||
]);
|
||||
renderTab();
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent(HINT);
|
||||
});
|
||||
|
||||
it("shows no hint when every key has one scope", async () => {
|
||||
vi.mocked(fetchApiKeys).mockResolvedValue([
|
||||
makeKey("read", ["shop-sync:read"]),
|
||||
makeKey("erp", ["erp-inventory:read"]),
|
||||
]);
|
||||
renderTab();
|
||||
|
||||
await screen.findByText("Schlüssel read");
|
||||
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByText(/in ERPNext als Secret/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("schlägt für den Shop wie bisher nur das Lesen vor", () => {
|
||||
renderWithClient(<ApiKeysTab />);
|
||||
expect(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("checkbox", { name: /Verkäufe und Rückbuchungen/ }),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
expect(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
describe("creating", () => {
|
||||
it("creates a read key by default", async () => {
|
||||
vi.mocked(createApiKey).mockResolvedValue({
|
||||
apiKey: makeKey("new", ["shop-sync:read"]),
|
||||
token: "t",
|
||||
});
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Schlüssel anlegen" }));
|
||||
|
||||
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:read"]);
|
||||
});
|
||||
|
||||
it('creates a write key when "Shop buchen" is chosen', async () => {
|
||||
vi.mocked(createApiKey).mockResolvedValue({
|
||||
apiKey: makeKey("new", ["shop-sync:write"]),
|
||||
token: "t",
|
||||
});
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(await screen.findByRole("radio", { name: "Shop buchen" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
|
||||
|
||||
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
|
||||
});
|
||||
|
||||
it("legt einen ERP-Schlüssel nur mit dem Inventar-Recht an und sagt, wohin er gehört", async () => {
|
||||
vi.mocked(createApiKey).mockResolvedValueOnce({
|
||||
apiKey: makeKey("erp", ["erp-inventory:read"], { name: "ERPNext" }),
|
||||
// Synthetischer Wert, kein echter Schlüssel.
|
||||
token: `ack_abcd1234_${"A".repeat(43)}`,
|
||||
});
|
||||
renderTab();
|
||||
|
||||
const name = screen.getByLabelText("Wofür ist der Schlüssel?");
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, "ERPNext");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "ERP-Inventar lesen" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
|
||||
|
||||
expect(vi.mocked(createApiKey).mock.calls[0][0]).toMatchObject({
|
||||
name: "ERPNext",
|
||||
scopes: ["erp-inventory:read"],
|
||||
});
|
||||
expect(await screen.findByText(/in ERPNext als Secret/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("schlägt für den Shop wie bisher das Lesen vor, genau ein Recht je Schlüssel", () => {
|
||||
renderTab();
|
||||
expect(screen.getByRole("radio", { name: "Shop lesen" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "Shop buchen" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("lässt ohne Berechtigung nichts anlegen", async () => {
|
||||
renderWithClient(<ApiKeysTab />);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ }));
|
||||
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
|
||||
describe("only grantable scopes", () => {
|
||||
it("bietet nur Rechte an, die der angemeldete Nutzer selbst hat", () => {
|
||||
ownPermissions = ["api-keys:manage", "shop-sync:read", "erp-inventory:read"];
|
||||
renderTab();
|
||||
expect(screen.getByRole("radio", { name: "Shop lesen" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("radio", { name: "Shop buchen" })).toBeNull();
|
||||
});
|
||||
|
||||
it("wählt ein erlaubtes Recht vor, wenn der Nutzer das Shop-Lesen nicht hat", async () => {
|
||||
ownPermissions = ["api-keys:manage", "erp-inventory:read"];
|
||||
vi.mocked(createApiKey).mockResolvedValue({
|
||||
apiKey: makeKey("erp", ["erp-inventory:read"]),
|
||||
token: "t",
|
||||
});
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
|
||||
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["erp-inventory:read"]);
|
||||
});
|
||||
|
||||
it("wählt nichts vor, was der Nutzer nicht vergeben darf, und erklärt es", () => {
|
||||
ownPermissions = ["api-keys:manage"];
|
||||
renderTab();
|
||||
expect(screen.queryAllByRole("radio")).toHaveLength(0);
|
||||
expect(screen.getByText(/keine Rechte, die ein API-Schlüssel tragen kann/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("zeigt in der Liste, was ein Schlüssel darf", async () => {
|
||||
vi.mocked(fetchApiKeys).mockResolvedValue([
|
||||
key({ id: "k2", name: "ERPNext", scopes: ["erp-inventory:read"] }),
|
||||
makeKey("k2", ["erp-inventory:read"], { name: "ERPNext" }),
|
||||
]);
|
||||
renderWithClient(<ApiKeysTab />);
|
||||
renderTab();
|
||||
expect(await screen.findByText("ERPNext")).toBeInTheDocument();
|
||||
expect(screen.getByText("Darf: Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet nur Rechte an, die der angemeldete Nutzer selbst hat", () => {
|
||||
ownPermissions = ["api-keys:manage", "shop-sync:read", "erp-inventory:read"];
|
||||
renderWithClient(<ApiKeysTab />);
|
||||
expect(screen.getByRole("checkbox", { name: /Shop-Daten lesen/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: /Inventar-Spiegel lesen/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox", { name: /Verkäufe und Rückbuchungen/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("wählt nichts vor, was der Nutzer nicht vergeben darf, und erklärt es", () => {
|
||||
ownPermissions = ["api-keys:manage"];
|
||||
renderWithClient(<ApiKeysTab />);
|
||||
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
|
||||
expect(screen.getByText(/keine Rechte, die ein API-Schlüssel tragen kann/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
|
||||
expect(screen.getByText("Darf:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,24 +5,31 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, KeyRound, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
|
||||
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
|
||||
import {
|
||||
API_KEY_SCOPE_LABELS,
|
||||
API_KEY_SCOPES,
|
||||
ApiKey,
|
||||
ApiKeyScope,
|
||||
apiKeyScopeLabel,
|
||||
} from "@/lib/api-keys/types";
|
||||
hasReadAndWriteScopes,
|
||||
} from "@/lib/api-keys/scopes";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
|
||||
|
||||
// Vorschlag wie bisher: der Wabenhain-Shop liest. Alles andere wird bewusst angekreuzt.
|
||||
const DEFAULT_SCOPES: readonly ApiKeyScope[] = ["shop-sync:read"];
|
||||
// Genau ein Recht je Schlüssel: Lesen und Buchen nie zusammen, und je
|
||||
// angebundenem System ein eigener Schlüssel. Vorschlag wie bisher: der Shop liest.
|
||||
const DEFAULT_SCOPE: ApiKeyScope = "shop-sync:read";
|
||||
const SCOPE_CHOICE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"shop-sync:read": "Shop lesen",
|
||||
"shop-sync:write": "Shop buchen",
|
||||
"erp-inventory:read": "ERP-Inventar lesen",
|
||||
};
|
||||
const SCOPE_LABEL_ID = "api-key-scope-label";
|
||||
|
||||
/** Wohin der frisch angelegte Schlüssel gehört - je nach Berechtigung. */
|
||||
function pasteHint(scopes: readonly ApiKeyScope[]) {
|
||||
@@ -52,8 +59,9 @@ function keyState(apiKey: ApiKey): { label: string; active: boolean } {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys for machine access (the Wabenhain shop sync) instead of a service
|
||||
* account with a password. The key is shown once; ApiCore keeps only a hash.
|
||||
* Keys for machine access (the Wabenhain shop sync, ERPNext's inventory
|
||||
* mirror) instead of a service account with a password. The key is shown
|
||||
* once; ApiCore keeps only a hash.
|
||||
*/
|
||||
export function ApiKeysTab() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -64,8 +72,12 @@ export function ApiKeysTab() {
|
||||
const [name, setName] = useState("Wabenhain-Shop");
|
||||
const [expiresInDays, setExpiresInDays] = useState("");
|
||||
const [allowedIps, setAllowedIps] = useState("");
|
||||
const [chosenScopes, setScopes] = useState<ApiKeyScope[]>([...DEFAULT_SCOPES]);
|
||||
const scopes = chosenScopes.filter((scope) => grantable.includes(scope));
|
||||
const [chosenScope, setScope] = useState<ApiKeyScope>(DEFAULT_SCOPE);
|
||||
// Fehlt dem Nutzer das gewählte Recht, gilt das erste, das er vergeben darf.
|
||||
const scope: ApiKeyScope | undefined = grantable.includes(chosenScope)
|
||||
? chosenScope
|
||||
: grantable[0];
|
||||
const scopeOptions = choiceOptions(SCOPE_CHOICE_LABELS, grantable);
|
||||
const [created, setCreated] = useState<{
|
||||
name: string;
|
||||
token: string;
|
||||
@@ -108,21 +120,16 @@ export function ApiKeysTab() {
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!scope) return;
|
||||
const days = expiresInDays ? Number(expiresInDays) : undefined;
|
||||
createMutation.mutate({
|
||||
name: name.trim(),
|
||||
scopes,
|
||||
scopes: [scope],
|
||||
expiresInDays: days,
|
||||
allowedIps: parseAllowlist(allowedIps),
|
||||
});
|
||||
}
|
||||
|
||||
function toggleScope(scope: ApiKeyScope, checked: boolean) {
|
||||
setScopes((current) =>
|
||||
API_KEY_SCOPES.filter((s) => (s === scope ? checked : current.includes(s))),
|
||||
);
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!created) return;
|
||||
try {
|
||||
@@ -136,9 +143,9 @@ export function ApiKeysTab() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
Ein API-Schlüssel erlaubt einem anderen System genau das, was angekreuzt ist – etwa dem
|
||||
Wabenhain-Shop, die freigegebenen Shop-Daten zu lesen, oder dem Buchhaltungssystem, den
|
||||
Inventar-Spiegel zu lesen. Er ist kein Benutzerkonto, hat kein Passwort und lässt sich
|
||||
Ein API-Schlüssel erlaubt einem anderen System genau eine Sache – etwa dem Wabenhain-Shop,
|
||||
die freigegebenen Shop-Daten zu lesen, oder dem Buchhaltungssystem, den Inventar-Spiegel zu
|
||||
lesen. Er ist kein Benutzerkonto, hat kein Passwort und lässt sich
|
||||
jederzeit widerrufen.
|
||||
</p>
|
||||
|
||||
@@ -181,6 +188,22 @@ export function ApiKeysTab() {
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label id={SCOPE_LABEL_ID}>Berechtigung</Label>
|
||||
{scope ? (
|
||||
<ChoiceGroup
|
||||
options={scopeOptions}
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
labelledBy={SCOPE_LABEL_ID}
|
||||
className="grid-cols-1 sm:grid-cols-3"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sie haben selbst keine Rechte, die ein API-Schlüssel tragen kann.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-44 flex-col gap-1.5">
|
||||
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
|
||||
<Input
|
||||
@@ -206,37 +229,20 @@ export function ApiKeysTab() {
|
||||
aria-describedby="api-key-ips-hint"
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="flex w-full flex-col gap-1.5">
|
||||
<legend className="mb-1.5 text-sm font-medium">Was darf der Schlüssel?</legend>
|
||||
{grantable.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sie haben selbst keine Rechte, die ein API-Schlüssel tragen kann.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{grantable.map((scope) => (
|
||||
<ToggleChip
|
||||
key={scope}
|
||||
label={API_KEY_SCOPE_LABELS[scope]}
|
||||
checked={scopes.includes(scope)}
|
||||
onChange={(checked) => toggleScope(scope, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<Button
|
||||
type="submit"
|
||||
size="touch"
|
||||
disabled={createMutation.isPending || !name.trim() || scopes.length === 0}
|
||||
disabled={createMutation.isPending || !name.trim() || !scope}
|
||||
>
|
||||
<KeyRound aria-hidden />
|
||||
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
|
||||
Je angebundenem System ein eigener Schlüssel mit nur den nötigen Rechten. IP-Adressen mit
|
||||
Komma trennen; leer = von überall. Unbekannt? Leer lassen, einmal abrufen lassen, dann hier
|
||||
„Adressen ändern“ – die zuletzt benutzte Adresse lässt sich übernehmen.
|
||||
{scope ? `Berechtigung: ${API_KEY_SCOPE_LABELS[scope]}. ` : null}Je angebundenem System ein
|
||||
eigener Schlüssel; Lesen und Buchen bekommen je einen eigenen. IP-Adressen mit Komma trennen;
|
||||
leer = von überall. Unbekannt? Leer lassen, einmal abrufen lassen, dann hier „Adressen
|
||||
ändern“ – die zuletzt benutzte Adresse lässt sich übernehmen.
|
||||
</p>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
@@ -264,9 +270,21 @@ export function ApiKeysTab() {
|
||||
<Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge>
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Darf: {apiKey.scopes.map(apiKeyScopeLabel).join(" · ")}
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">Darf:</span>
|
||||
{apiKey.scopes.map((keyScope) => (
|
||||
<Badge key={keyScope} variant="secondary">
|
||||
{apiKeyScopeLabel(keyScope)}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
{state.active && hasReadAndWriteScopes(apiKey) ? (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte
|
||||
Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in
|
||||
Wabenhain eintragen, dann diesen widerrufen.
|
||||
</p>
|
||||
) : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
|
||||
{formatDate(apiKey.lastUsedAt)}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { SocialMediaTab } from "./social-media-tab";
|
||||
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
|
||||
import type { SiteSettings } from "@/lib/site-settings/types";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/site-settings/api", () => ({
|
||||
fetchSiteSettings: vi.fn(),
|
||||
updateSiteSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const settings: SiteSettings = {
|
||||
id: "singleton",
|
||||
youtubeUrl: null,
|
||||
instagramUrl: null,
|
||||
tiktokUrl: null,
|
||||
websiteUrl: null,
|
||||
holderName: null,
|
||||
holderAddress: null,
|
||||
originCountry: "Deutschland",
|
||||
updatedAt: "2026-10-13T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function renderTab() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SocialMediaTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SocialMediaTab origin country", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchSiteSettings).mockReset();
|
||||
vi.mocked(updateSiteSettings).mockReset();
|
||||
vi.mocked(fetchSiteSettings).mockResolvedValue(settings);
|
||||
vi.mocked(updateSiteSettings).mockResolvedValue(settings);
|
||||
});
|
||||
|
||||
it("shows the saved country with its hint", async () => {
|
||||
renderTab();
|
||||
expect(await screen.findByLabelText(/^Herkunftsland/)).toHaveValue("Deutschland");
|
||||
expect(screen.getByText(/Erscheint auf der Herkunftsseite/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a changed country trimmed", async () => {
|
||||
renderTab();
|
||||
const input = await screen.findByLabelText(/^Herkunftsland/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, " Österreich ");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({
|
||||
originCountry: "Österreich",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not save an emptied country (the server cannot hold none)", async () => {
|
||||
renderTab();
|
||||
await userEvent.clear(await screen.findByLabelText(/^Herkunftsland/));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(updateSiteSettings).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText("Bitte ein Land angeben.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
|
||||
// Mounted only once `settings` has arrived, so its useState initial values
|
||||
// already reflect the fetched data - no synchronization effect needed.
|
||||
@@ -20,6 +21,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
|
||||
const [holderName, setHolderName] = useState(settings.holderName ?? "");
|
||||
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
|
||||
const [originCountry, setOriginCountry] = useState(settings.originCountry);
|
||||
const [originCountryError, setOriginCountryError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateSiteSettings,
|
||||
@@ -32,6 +35,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// NOT NULL in the database: it can be changed, not emptied.
|
||||
if (originCountry.trim().length < 2) {
|
||||
setOriginCountryError("Bitte ein Land angeben.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
youtubeUrl,
|
||||
instagramUrl,
|
||||
@@ -39,6 +47,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
websiteUrl,
|
||||
holderName,
|
||||
holderAddress,
|
||||
originCountry: originCountry.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +99,26 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
onChange={(event) => setWebsiteUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Herkunftsland"
|
||||
required
|
||||
hint="Erscheint auf der Herkunftsseite, z. B. „Deutschland“."
|
||||
error={originCountryError}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={80}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={originCountry}
|
||||
onChange={(event) => {
|
||||
setOriginCountry(event.target.value);
|
||||
setOriginCountryError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
|
||||
<legend className="px-1 text-sm font-medium">Bienenhalter (für das Bestandsbuch)</legend>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Tag } from "lucide-react";
|
||||
import { createLabelTemplate } from "@/lib/label-templates/api";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer";
|
||||
|
||||
@@ -12,6 +13,11 @@ export default function NewLabelTemplatePage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<FormBackLink
|
||||
href="/settings/automation"
|
||||
label="Zur Automatisierung"
|
||||
dirty={false}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Tag}
|
||||
title="Neue Etikettenvorlage"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { API_KEY_SCOPES, apiKeyScopeLabel, hasReadAndWriteScopes } from "./scopes";
|
||||
|
||||
describe("hasReadAndWriteScopes", () => {
|
||||
it("is true for a key that may read and book", () => {
|
||||
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the ERP inventory scope as reading", () => {
|
||||
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:write"] })).toBe(true);
|
||||
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:read"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for single-scope keys", () => {
|
||||
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read"] })).toBe(false);
|
||||
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:write"] })).toBe(false);
|
||||
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API key scopes", () => {
|
||||
it("match the backend allowlist", () => {
|
||||
expect(API_KEY_SCOPES).toEqual(["shop-sync:read", "shop-sync:write", "erp-inventory:read"]);
|
||||
});
|
||||
|
||||
it("label known scopes and pass unknown ones through", () => {
|
||||
expect(apiKeyScopeLabel("erp-inventory:read")).toBe("Inventar-Spiegel lesen (ERPNext)");
|
||||
expect(apiKeyScopeLabel("future:scope")).toBe("future:scope");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ApiKey } from "./types";
|
||||
|
||||
/** Berechtigungen, die ein API-Schlüssel tragen kann - wie API_KEY_SCOPES im Backend. */
|
||||
export const API_KEY_SCOPES = ["shop-sync:read", "shop-sync:write", "erp-inventory:read"] as const;
|
||||
|
||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||
|
||||
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)",
|
||||
"erp-inventory:read": "Inventar-Spiegel lesen (ERPNext)",
|
||||
};
|
||||
|
||||
/** Buchende Rechte - wie API_KEY_WRITE_SCOPES im Backend; alle anderen lesen nur. */
|
||||
const WRITE_SCOPES: readonly string[] = ["shop-sync:write"];
|
||||
|
||||
/** Label für die Anzeige; unbekannte Rechte (neueres Backend) unverändert. */
|
||||
export function apiKeyScopeLabel(scope: string): string {
|
||||
return (API_KEY_SCOPE_LABELS as Record<string, string>)[scope] ?? scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The ERP inventory scope counts as reading.
|
||||
*/
|
||||
export function hasReadAndWriteScopes(apiKey: Pick<ApiKey, "scopes">): boolean {
|
||||
const isWrite = (scope: string) => WRITE_SCOPES.includes(scope);
|
||||
return apiKey.scopes.some(isWrite) && apiKey.scopes.some((scope) => !isWrite(scope));
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
/** Berechtigungen, die ein API-Schlüssel tragen kann - wie API_KEY_SCOPES im Backend. */
|
||||
export const API_KEY_SCOPES = ["shop-sync:read", "shop-sync:write", "erp-inventory:read"] as const;
|
||||
import type { ApiKeyScope } from "./scopes";
|
||||
|
||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||
|
||||
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
|
||||
"shop-sync:write": "Verkäufe und Rückbuchungen des Shops buchen (Wabenhain)",
|
||||
"erp-inventory:read": "Inventar-Spiegel lesen (ERPNext)",
|
||||
};
|
||||
|
||||
/** Label für die Anzeige; unbekannte Rechte (neueres Backend) unverändert. */
|
||||
export function apiKeyScopeLabel(scope: string): string {
|
||||
return (API_KEY_SCOPE_LABELS as Record<string, string>)[scope] ?? scope;
|
||||
}
|
||||
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 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user