feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
|
||||
import { fetchColonyOverview } from "@/lib/apiaries/field-api";
|
||||
import { readRound } from "@/lib/rounds/round-state";
|
||||
import type { ColonyOverview, ColonyOverviewRow } from "@/lib/apiaries/field-types";
|
||||
|
||||
vi.mock("@/lib/apiaries/field-api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/apiaries/field-api")>()),
|
||||
fetchColonyOverview: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
|
||||
|
||||
const apiary = { id: "a-1", name: "Obstwiese" };
|
||||
|
||||
function row(overrides: Partial<ColonyOverviewRow>): ColonyOverviewRow {
|
||||
return {
|
||||
id: "c-1",
|
||||
code: "A1",
|
||||
status: "ACTIVE",
|
||||
strength: 6,
|
||||
broodStatus: "GOOD",
|
||||
feedStatus: "GOOD",
|
||||
healthStatus: "GOOD",
|
||||
lastInspectionDate: new Date().toISOString(),
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(rows: ColonyOverviewRow[]): ColonyOverview {
|
||||
return { apiaryId: "a-1", inspectionsVisible: true, rows };
|
||||
}
|
||||
|
||||
function renderCard() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<StartRoundButton apiary={apiary} />
|
||||
<ApiaryColoniesCard apiary={apiary} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiaryColoniesCard", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
vi.mocked(fetchColonyOverview).mockReset();
|
||||
});
|
||||
|
||||
it("lists living colonies with light and reasons", async () => {
|
||||
vi.mocked(fetchColonyOverview).mockResolvedValue(
|
||||
overview([
|
||||
row({ id: "c-1", code: "A1" }),
|
||||
row({ id: "c-2", code: "A2", status: "QUEENLESS", openWarnings: 2 }),
|
||||
]),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Völker (2)" })).toBeInTheDocument();
|
||||
const first = screen.getByRole("link", { name: /A1/ });
|
||||
expect(first).toHaveAttribute("href", "/colonies/c-1");
|
||||
expect(within(first).getByText("Gut")).toBeInTheDocument();
|
||||
const second = screen.getByRole("link", { name: /A2/ });
|
||||
expect(within(second).getByText("Kritisch")).toBeInTheDocument();
|
||||
expect(within(second).getByText("Weisellos · 2 Warnungen")).toBeInTheDocument();
|
||||
expect(within(first).getByText("Aktiv")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("„Runde starten\" opens the first colony's inspection with the round param", async () => {
|
||||
vi.mocked(fetchColonyOverview).mockResolvedValue(
|
||||
overview([row({ id: "c-1", code: "A1" }), row({ id: "c-2", code: "A2" })]),
|
||||
);
|
||||
renderCard();
|
||||
|
||||
await screen.findByRole("heading", { name: "Völker (2)" });
|
||||
const start = screen.getByRole("button", { name: "Runde starten" });
|
||||
expect(start).toHaveAttribute("href", "/inspections/new?colonyId=c-1&round=a-1");
|
||||
await userEvent.click(start);
|
||||
expect(readRound("user-1")).toMatchObject({
|
||||
apiaryId: "a-1",
|
||||
apiaryName: "Obstwiese",
|
||||
colonies: [
|
||||
{ id: "c-1", code: "A1" },
|
||||
{ id: "c-2", code: "A2" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("„Runde starten\" is disabled without colonies", async () => {
|
||||
vi.mocked(fetchColonyOverview).mockResolvedValue(overview([]));
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText(/Noch keine Völker/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Runde starten" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Runde starten" })).not.toHaveAttribute("href");
|
||||
});
|
||||
|
||||
it("shows a retryable error instead of an empty list when loading fails", async () => {
|
||||
vi.mocked(fetchColonyOverview).mockRejectedValue(new Error("boom"));
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Völker konnten nicht geladen werden");
|
||||
expect(screen.getByRole("button", { name: "Runde starten" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Play, Sprout } from "lucide-react";
|
||||
import { useColonyOverview } from "@/lib/apiaries/use-colony-overview";
|
||||
import { useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { COLONY_STATUS_LABELS } from "@/lib/colonies/labels";
|
||||
import { colonyStatusLight } from "@/lib/colonies/status-light";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { startRound } from "@/lib/rounds/round-state";
|
||||
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface ApiaryRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** The apiary page's one main action: walk through all colonies. */
|
||||
export function StartRoundButton({ apiary }: { apiary: ApiaryRef }) {
|
||||
const userId = useCurrentUserId();
|
||||
const { data } = useColonyOverview(apiary.id);
|
||||
const rows = data?.rows ?? [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Button size="touch" disabled>
|
||||
<Play aria-hidden />
|
||||
Runde starten
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
href={`/inspections/new?colonyId=${encodeURIComponent(rows[0].id)}&round=${encodeURIComponent(apiary.id)}`}
|
||||
onClick={() => {
|
||||
if (userId) startRound(userId, apiary, rows);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Play aria-hidden />
|
||||
Runde starten
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiaryColoniesCard({ apiary }: { apiary: ApiaryRef }) {
|
||||
const query = useColonyOverview(apiary.id);
|
||||
const overview = query.data;
|
||||
const today = todayLocalIsoDate();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h2>Völker ({overview?.rows.length ?? 0})</h2>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{query.isLoading ? <LoadingState what="Völker" /> : null}
|
||||
{!query.isLoading && !overview ? (
|
||||
<ErrorState what="Völker" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
|
||||
) : null}
|
||||
{overview && overview.rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Sprout}
|
||||
title="Noch keine Völker an diesem Stand"
|
||||
description="Sobald ein Volk hier steht, erscheint es mit seiner Ampel."
|
||||
/>
|
||||
) : null}
|
||||
{overview?.rows.map((row) => {
|
||||
const { light, reasons } = colonyStatusLight(row, {
|
||||
today,
|
||||
inspectionsVisible: overview.inspectionsVisible,
|
||||
});
|
||||
return (
|
||||
<Link
|
||||
key={row.id}
|
||||
href={`/colonies/${row.id}`}
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{row.code}</span>
|
||||
<StatusLightBadge light={light} />
|
||||
<span className="text-sm text-muted-foreground">{COLONY_STATUS_LABELS[row.status]}</span>
|
||||
</span>
|
||||
{reasons.length > 0 ? (
|
||||
<span className="text-sm text-muted-foreground">{reasons.join(" · ")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const apiary: Apiary = {
|
||||
latitude: 48.47,
|
||||
longitude: 10.6,
|
||||
address: "Hauptstr. 1",
|
||||
publicLocationLabel: "Nordhessen",
|
||||
status: "ACTIVE",
|
||||
notes: "Schlüssel beim Nachbarn",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -41,7 +42,7 @@ describe("ApiaryEditCard", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen"]) {
|
||||
for (const label of ["Beschreibung", "Standort", "Breitengrad", "Längengrad", "Adresse", "Notizen", "Ort für die Herkunftsseite"]) {
|
||||
await userEvent.clear(screen.getByLabelText(new RegExp(`^${label}`)));
|
||||
}
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
@@ -54,6 +55,7 @@ describe("ApiaryEditCard", () => {
|
||||
longitude: null,
|
||||
address: null,
|
||||
notes: null,
|
||||
publicLocationLabel: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import { PUBLIC_LOCATION_HINT } from "../new/apiary-form-values";
|
||||
|
||||
export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(apiary.name);
|
||||
@@ -23,6 +25,9 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
const [longitude, setLongitude] = useState(apiary.longitude?.toString() ?? "");
|
||||
const [address, setAddress] = useState(apiary.address ?? "");
|
||||
const [notes, setNotes] = useState(apiary.notes ?? "");
|
||||
const [publicLocationLabel, setPublicLocationLabel] = useState(
|
||||
apiary.publicLocationLabel ?? "",
|
||||
);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
@@ -36,6 +41,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
longitude: emptyToNull(longitude, Number),
|
||||
address: emptyToNull(address),
|
||||
notes: emptyToNull(notes),
|
||||
publicLocationLabel: emptyToNull(publicLocationLabel),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["apiaries"] });
|
||||
@@ -159,6 +165,18 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Ort für die Herkunftsseite" hint={PUBLIC_LOCATION_HINT}>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
maxLength={100}
|
||||
aria-describedby={describedBy}
|
||||
value={publicLocationLabel}
|
||||
onChange={(event) => setPublicLocationLabel(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Notizen">
|
||||
{({ id }) => (
|
||||
<Textarea id={id} value={notes} onChange={(event) => setNotes(event.target.value)} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { FileText, MapPin, Plus } from "lucide-react";
|
||||
@@ -10,7 +9,7 @@ import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||
import { DetailHeader, DetailQueryFallback, DetailStatus } from "@/components/detail-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ApiaryColoniesCard, StartRoundButton } from "./apiary-colonies-card";
|
||||
import { ApiaryEditCard } from "./apiary-edit-card";
|
||||
import { WeatherCard } from "./weather-card";
|
||||
|
||||
@@ -51,17 +50,13 @@ export default function ApiaryDetailPage() {
|
||||
label={apiary.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||
/>
|
||||
}
|
||||
primaryAction={
|
||||
<Button
|
||||
size="touch"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
|
||||
>
|
||||
<Plus aria-hidden />
|
||||
Ernte erfassen
|
||||
</Button>
|
||||
}
|
||||
primaryAction={<StartRoundButton apiary={{ id: apiary.id, name: apiary.name }} />}
|
||||
moreActions={[
|
||||
{
|
||||
label: "Ernte erfassen",
|
||||
icon: Plus,
|
||||
href: `/harvests/new?apiaryId=${apiary.id}`,
|
||||
},
|
||||
{
|
||||
label: treatmentLogMutation.isPending
|
||||
? "Bestandsbuch wird erstellt…"
|
||||
@@ -83,6 +78,7 @@ export default function ApiaryDetailPage() {
|
||||
/>
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ApiaryColoniesCard apiary={{ id: apiary.id, name: apiary.name }} />
|
||||
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||
<div>
|
||||
<QrCodeDialog entityType="apiaries" id={apiary.id} label="QR-Code für den Stand" />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialApiaryValues, toApiaryInput } from "./apiary-form-values";
|
||||
|
||||
describe("toApiaryInput", () => {
|
||||
it("sends a filled public location label trimmed", () => {
|
||||
const input = toApiaryInput({
|
||||
...initialApiaryValues(),
|
||||
name: "Hausgarten",
|
||||
publicLocationLabel: " Nordhessen ",
|
||||
});
|
||||
expect(input.publicLocationLabel).toBe("Nordhessen");
|
||||
});
|
||||
|
||||
it("sends an empty public location label as null", () => {
|
||||
const input = toApiaryInput({ ...initialApiaryValues(), name: "Hausgarten" });
|
||||
expect(input.publicLocationLabel).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ApiaryInput } from "@/lib/apiaries/types";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
|
||||
/** Hint of the public origin field, shared by the create form and the edit card. */
|
||||
export const PUBLIC_LOCATION_HINT =
|
||||
"Wird öffentlich auf der Herkunftsseite der Honiggläser gezeigt, z. B. „Nordhessen“. Nie die genaue Adresse eintragen. Leer lassen: Es erscheint nur das Land.";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
export interface ApiaryFormValues {
|
||||
name: string;
|
||||
@@ -9,6 +13,7 @@ export interface ApiaryFormValues {
|
||||
longitude: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
publicLocationLabel: string;
|
||||
}
|
||||
|
||||
export type ApiaryFormErrors = Partial<Record<keyof ApiaryFormValues, string>>;
|
||||
@@ -21,6 +26,7 @@ export function initialApiaryValues(): ApiaryFormValues {
|
||||
longitude: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
publicLocationLabel: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,5 +50,6 @@ export function toApiaryInput(values: ApiaryFormValues): ApiaryInput {
|
||||
longitude: emptyToNull(values.longitude, Number),
|
||||
address: emptyToNull(values.address),
|
||||
notes: emptyToNull(values.notes),
|
||||
publicLocationLabel: emptyToNull(values.publicLocationLabel),
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,7 @@ describe("NewApiaryPage", () => {
|
||||
longitude: null,
|
||||
address: "Dorfstraße 1",
|
||||
notes: null,
|
||||
publicLocationLabel: null,
|
||||
});
|
||||
expect(push).toHaveBeenCalledWith("/apiaries/apiary-3");
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
initialApiaryValues,
|
||||
toApiaryInput,
|
||||
validateApiary,
|
||||
PUBLIC_LOCATION_HINT,
|
||||
} from "./apiary-form-values";
|
||||
|
||||
const PERMISSION_DENIED = 1;
|
||||
@@ -120,6 +122,12 @@ export default function NewApiaryPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<FormBackLink
|
||||
href="/apiaries"
|
||||
label="Zu den Bienenständen"
|
||||
dirty={dirty}
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={MapPin}
|
||||
title="Neuer Bienenstand"
|
||||
@@ -229,6 +237,19 @@ export default function NewApiaryPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Ort für die Herkunftsseite" hint={PUBLIC_LOCATION_HINT}>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={100}
|
||||
aria-describedby={describedBy}
|
||||
value={values.publicLocationLabel}
|
||||
onChange={(event) =>
|
||||
set("publicLocationLabel", event.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("ApiariesPage", () => {
|
||||
address: null,
|
||||
status: "ACTIVE",
|
||||
notes: null,
|
||||
publicLocationLabel: null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deactivatedAt: null,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { honeyBatchOptionLabel } from "@/lib/honey-batches/labels";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
@@ -138,6 +139,12 @@ export default function NewBottlingBatchPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<FormBackLink
|
||||
href="/bottling"
|
||||
label="Zu den Abfüllungen"
|
||||
dirty={dirty}
|
||||
draft={draft}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Package}
|
||||
title="Neue Abfüllung"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ApiKeysTab } from "./api-keys-tab";
|
||||
import { createApiKey, fetchApiKeys } from "@/lib/api-keys/api";
|
||||
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/api-keys/api", () => ({
|
||||
fetchApiKeys: vi.fn(),
|
||||
createApiKey: vi.fn(),
|
||||
revokeApiKey: vi.fn(),
|
||||
updateApiKeyAllowedIps: vi.fn(),
|
||||
}));
|
||||
|
||||
const HINT = /Dieser Schlüssel darf lesen und buchen/;
|
||||
|
||||
function makeKey(id: string, scopes: ApiKeyScope[]): ApiKey {
|
||||
return {
|
||||
id,
|
||||
name: `Schlüssel ${id}`,
|
||||
prefix: `ack_${id}`,
|
||||
scopes,
|
||||
createdByUserId: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
lastUsedIp: null,
|
||||
allowedIps: [],
|
||||
expiresAt: null,
|
||||
revokedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ApiKeysTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiKeysTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the separation hint only for a key with both scopes", async () => {
|
||||
vi.mocked(fetchApiKeys).mockResolvedValue([
|
||||
makeKey("both", ["shop-sync:read", "shop-sync:write"]),
|
||||
makeKey("read", ["shop-sync:read"]),
|
||||
]);
|
||||
renderTab();
|
||||
|
||||
const hints = await screen.findAllByRole("status");
|
||||
expect(hints).toHaveLength(1);
|
||||
expect(hints[0]).toHaveTextContent(HINT);
|
||||
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
|
||||
});
|
||||
|
||||
it("shows no hint when every key has one scope", async () => {
|
||||
vi.mocked(fetchApiKeys).mockResolvedValue([makeKey("read", ["shop-sync:read"])]);
|
||||
renderTab();
|
||||
|
||||
await screen.findByText("Schlüssel read");
|
||||
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a read key by default", async () => {
|
||||
vi.mocked(fetchApiKeys).mockResolvedValue([]);
|
||||
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:read"]), token: "t" });
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Schlüssel anlegen" }));
|
||||
|
||||
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:read"]);
|
||||
});
|
||||
|
||||
it('creates a write key when "Buchen" is chosen', async () => {
|
||||
vi.mocked(fetchApiKeys).mockResolvedValue([]);
|
||||
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:write"]), token: "t" });
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(await screen.findByRole("radio", { name: "Buchen" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
|
||||
|
||||
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, KeyRound, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
|
||||
import { API_KEY_SCOPE_LABELS, ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
|
||||
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
|
||||
import { API_KEY_SCOPE_LABELS, hasCombinedShopScopes } from "@/lib/api-keys/scopes";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,7 +15,13 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
|
||||
|
||||
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
|
||||
// Reading and booking are separate keys, so a leaked key can only do one.
|
||||
const SCOPE_CHOICE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"shop-sync:read": "Lesen",
|
||||
"shop-sync:write": "Buchen",
|
||||
};
|
||||
const SCOPE_OPTIONS = choiceOptions(SCOPE_CHOICE_LABELS);
|
||||
const SCOPE_LABEL_ID = "api-key-scope-label";
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
return value
|
||||
@@ -38,6 +46,7 @@ export function ApiKeysTab() {
|
||||
const [name, setName] = useState("Wabenhain-Shop");
|
||||
const [expiresInDays, setExpiresInDays] = useState("");
|
||||
const [allowedIps, setAllowedIps] = useState("");
|
||||
const [scope, setScope] = useState<ApiKeyScope>("shop-sync:read");
|
||||
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -75,7 +84,7 @@ export function ApiKeysTab() {
|
||||
const days = expiresInDays ? Number(expiresInDays) : undefined;
|
||||
createMutation.mutate({
|
||||
name: name.trim(),
|
||||
scopes: DEFAULT_SCOPES,
|
||||
scopes: [scope],
|
||||
expiresInDays: days,
|
||||
allowedIps: parseAllowlist(allowedIps),
|
||||
});
|
||||
@@ -139,6 +148,16 @@ export function ApiKeysTab() {
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-56 flex-col gap-1.5">
|
||||
<Label id={SCOPE_LABEL_ID}>Berechtigung</Label>
|
||||
<ChoiceGroup
|
||||
options={SCOPE_OPTIONS}
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
labelledBy={SCOPE_LABEL_ID}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-44 flex-col gap-1.5">
|
||||
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
|
||||
<Input
|
||||
@@ -170,7 +189,7 @@ export function ApiKeysTab() {
|
||||
</Button>
|
||||
</form>
|
||||
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
|
||||
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}. IP-Adressen mit Komma trennen; leer
|
||||
Berechtigung: {API_KEY_SCOPE_LABELS[scope]}. Lesen und Buchen bekommen je einen eigenen Schlüssel. IP-Adressen mit Komma trennen; leer
|
||||
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ –
|
||||
die zuletzt benutzte Adresse lässt sich übernehmen.
|
||||
</p>
|
||||
@@ -200,6 +219,20 @@ export function ApiKeysTab() {
|
||||
<Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge>
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</span>
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{apiKey.scopes.map((keyScope) => (
|
||||
<Badge key={keyScope} variant="secondary">
|
||||
{API_KEY_SCOPE_LABELS[keyScope]}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
{state.active && hasCombinedShopScopes(apiKey) ? (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte
|
||||
Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in
|
||||
Wabenhain eintragen, dann diesen widerrufen.
|
||||
</p>
|
||||
) : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
|
||||
{formatDate(apiKey.lastUsedAt)}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { SocialMediaTab } from "./social-media-tab";
|
||||
import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api";
|
||||
import type { SiteSettings } from "@/lib/site-settings/types";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/site-settings/api", () => ({
|
||||
fetchSiteSettings: vi.fn(),
|
||||
updateSiteSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const settings: SiteSettings = {
|
||||
id: "singleton",
|
||||
youtubeUrl: null,
|
||||
instagramUrl: null,
|
||||
tiktokUrl: null,
|
||||
websiteUrl: null,
|
||||
holderName: null,
|
||||
holderAddress: null,
|
||||
originCountry: "Deutschland",
|
||||
updatedAt: "2026-10-13T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function renderTab() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SocialMediaTab />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SocialMediaTab origin country", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchSiteSettings).mockReset();
|
||||
vi.mocked(updateSiteSettings).mockReset();
|
||||
vi.mocked(fetchSiteSettings).mockResolvedValue(settings);
|
||||
vi.mocked(updateSiteSettings).mockResolvedValue(settings);
|
||||
});
|
||||
|
||||
it("shows the saved country with its hint", async () => {
|
||||
renderTab();
|
||||
expect(await screen.findByLabelText(/^Herkunftsland/)).toHaveValue("Deutschland");
|
||||
expect(screen.getByText(/Erscheint auf der Herkunftsseite/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a changed country trimmed", async () => {
|
||||
renderTab();
|
||||
const input = await screen.findByLabelText(/^Herkunftsland/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, " Österreich ");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({
|
||||
originCountry: "Österreich",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not save an emptied country (the server cannot hold none)", async () => {
|
||||
renderTab();
|
||||
await userEvent.clear(await screen.findByLabelText(/^Herkunftsland/));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(updateSiteSettings).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText("Bitte ein Land angeben.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
|
||||
// Mounted only once `settings` has arrived, so its useState initial values
|
||||
// already reflect the fetched data - no synchronization effect needed.
|
||||
@@ -20,6 +21,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
|
||||
const [holderName, setHolderName] = useState(settings.holderName ?? "");
|
||||
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
|
||||
const [originCountry, setOriginCountry] = useState(settings.originCountry);
|
||||
const [originCountryError, setOriginCountryError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateSiteSettings,
|
||||
@@ -32,6 +35,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// NOT NULL in the database: it can be changed, not emptied.
|
||||
if (originCountry.trim().length < 2) {
|
||||
setOriginCountryError("Bitte ein Land angeben.");
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
youtubeUrl,
|
||||
instagramUrl,
|
||||
@@ -39,6 +47,7 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
websiteUrl,
|
||||
holderName,
|
||||
holderAddress,
|
||||
originCountry: originCountry.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +99,26 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
||||
onChange={(event) => setWebsiteUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Herkunftsland"
|
||||
required
|
||||
hint="Erscheint auf der Herkunftsseite, z. B. „Deutschland“."
|
||||
error={originCountryError}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={80}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
value={originCountry}
|
||||
onChange={(event) => {
|
||||
setOriginCountry(event.target.value);
|
||||
setOriginCountryError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
|
||||
<legend className="px-1 text-sm font-medium">Bienenhalter (für das Bestandsbuch)</legend>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Tag } from "lucide-react";
|
||||
import { createLabelTemplate } from "@/lib/label-templates/api";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer";
|
||||
|
||||
@@ -12,6 +13,11 @@ export default function NewLabelTemplatePage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<FormBackLink
|
||||
href="/settings/automation"
|
||||
label="Zur Automatisierung"
|
||||
dirty={false}
|
||||
/>
|
||||
<PageHeader
|
||||
icon={Tag}
|
||||
title="Neue Etikettenvorlage"
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user