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());
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AlertTriangle, CheckCircle2, XCircle, type LucideIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { COLONY_CONDITION_TONE_CLASS } from "@/lib/colonies/labels";
|
||||
import type { StatusLight } from "@/lib/colonies/status-light";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LIGHTS: Record<StatusLight, { label: string; icon: LucideIcon; tone: string }> = {
|
||||
green: { label: "Gut", icon: CheckCircle2, tone: COLONY_CONDITION_TONE_CLASS.GOOD },
|
||||
yellow: { label: "Achtung", icon: AlertTriangle, tone: COLONY_CONDITION_TONE_CLASS.AVERAGE },
|
||||
red: { label: "Kritisch", icon: XCircle, tone: COLONY_CONDITION_TONE_CLASS.CRITICAL },
|
||||
};
|
||||
|
||||
/** The traffic light as icon plus word - the colour only underlines it. */
|
||||
export function StatusLightBadge({ light, className }: { light: StatusLight; className?: string }) {
|
||||
const { label, icon: Icon, tone } = LIGHTS[light];
|
||||
return (
|
||||
<Badge className={cn(tone, className)}>
|
||||
<Icon aria-hidden />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
triggerLabel: string;
|
||||
/**
|
||||
* Accessible name when several triggers share one visible label (e.g. a
|
||||
* „Verwerfen" per list row). Start it with the visible label (WCAG 2.5.3).
|
||||
*/
|
||||
triggerAriaLabel?: string;
|
||||
triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
|
||||
triggerSize?: VariantProps<typeof buttonVariants>["size"];
|
||||
triggerClassName?: string;
|
||||
@@ -29,6 +34,7 @@ interface ConfirmDialogProps {
|
||||
|
||||
export function ConfirmDialog({
|
||||
triggerLabel,
|
||||
triggerAriaLabel,
|
||||
triggerVariant = "outline",
|
||||
triggerSize = "default",
|
||||
triggerClassName,
|
||||
@@ -49,6 +55,7 @@ export function ConfirmDialog({
|
||||
variant={triggerVariant}
|
||||
size={triggerSize}
|
||||
className={cn(triggerClassName)}
|
||||
aria-label={triggerAriaLabel}
|
||||
disabled={triggerDisabled}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchDocuments, updateDocument, uploadDocument } from "@/lib/documents/api";
|
||||
import { MAX_DOCUMENT_UPLOAD_BYTES } from "@/lib/documents/limits";
|
||||
import { PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
|
||||
import { DocumentUploadCard } from "./document-upload-card";
|
||||
|
||||
vi.mock("@/lib/documents/api", () => ({
|
||||
fetchDocuments: vi.fn(),
|
||||
uploadDocument: vi.fn(),
|
||||
updateDocument: vi.fn(),
|
||||
deleteDocument: vi.fn(),
|
||||
downloadDocument: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/photos/reencode", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/photos/reencode")>()),
|
||||
preparePhoto: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const CLEAN_PHOTO = new Blob(["clean jpeg"], { type: "image/jpeg" });
|
||||
const STORED = {
|
||||
id: "doc-1",
|
||||
entityType: "INSPECTION" as const,
|
||||
entityId: "i-1",
|
||||
fileName: "rahmen.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
sizeBytes: 2048,
|
||||
uploadedByUserId: "user-1",
|
||||
createdAt: "2026-10-13T10:00:00.000Z",
|
||||
};
|
||||
|
||||
function fileInput(container: HTMLElement): HTMLInputElement {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
if (!input) throw new Error("no file input");
|
||||
return input;
|
||||
}
|
||||
|
||||
function renderCard(onOuterSubmit?: () => void) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const card = <DocumentUploadCard entityType="INSPECTION" entityId="i-1" />;
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
{onOuterSubmit ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onOuterSubmit();
|
||||
}}
|
||||
>
|
||||
{card}
|
||||
</form>
|
||||
) : (
|
||||
card
|
||||
)}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DocumentUploadCard", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchDocuments).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(uploadDocument).mockReset().mockResolvedValue(STORED);
|
||||
vi.mocked(updateDocument).mockReset().mockResolvedValue(STORED);
|
||||
vi.mocked(preparePhoto).mockReset().mockResolvedValue(CLEAN_PHOTO);
|
||||
vi.mocked(toast.error).mockReset();
|
||||
});
|
||||
|
||||
it("re-encodes images before upload, PDFs unchanged", async () => {
|
||||
const { container } = renderCard();
|
||||
const photo = new File(["png with metadata"], "Beute 3.png", { type: "image/png" });
|
||||
const invoice = new File(["%PDF-1.7"], "rechnung.pdf", { type: "application/pdf" });
|
||||
|
||||
await userEvent.upload(fileInput(container), photo);
|
||||
await waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1));
|
||||
await userEvent.upload(fileInput(container), invoice);
|
||||
await waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(preparePhoto).toHaveBeenCalledTimes(1);
|
||||
expect(preparePhoto).toHaveBeenCalledWith(photo);
|
||||
expect(uploadDocument).toHaveBeenNthCalledWith(1, "INSPECTION", "i-1", CLEAN_PHOTO, {
|
||||
fileName: "Beute 3.jpg",
|
||||
});
|
||||
expect(uploadDocument).toHaveBeenNthCalledWith(2, "INSPECTION", "i-1", invoice);
|
||||
});
|
||||
|
||||
it("does not upload a photo that stays too large, and says why", async () => {
|
||||
vi.mocked(preparePhoto).mockRejectedValue(new PhotoTooLargeError());
|
||||
const { container } = renderCard();
|
||||
|
||||
await userEvent.upload(
|
||||
fileInput(container),
|
||||
new File(["huge"], "panorama.jpg", { type: "image/jpeg" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Foto zu groß (max. 10 MB)"),
|
||||
);
|
||||
expect(uploadDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a PDF above the server's upload limit before sending it", async () => {
|
||||
const { container } = renderCard();
|
||||
const invoice = new File(["%PDF-1.7"], "scan.pdf", { type: "application/pdf" });
|
||||
Object.defineProperty(invoice, "size", { value: MAX_DOCUMENT_UPLOAD_BYTES + 1 });
|
||||
|
||||
await userEvent.upload(fileInput(container), invoice);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Datei zu groß (max. 10 MB)");
|
||||
expect(uploadDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renaming a file never submits the form the card sits in", async () => {
|
||||
vi.mocked(fetchDocuments).mockResolvedValue([STORED]);
|
||||
const outerSubmit = vi.fn();
|
||||
renderCard(outerSubmit);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Umbenennen" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Datei umbenennen" });
|
||||
await userEvent.clear(within(dialog).getByLabelText("Dateiname"));
|
||||
await userEvent.type(within(dialog).getByLabelText("Dateiname"), "brutwabe.jpg{Enter}");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateDocument).toHaveBeenCalledWith("doc-1", { fileName: "brutwabe.jpg" }),
|
||||
);
|
||||
expect(outerSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
uploadDocument,
|
||||
} from "@/lib/documents/api";
|
||||
import { DocumentEntityType, DocumentSummary } from "@/lib/documents/types";
|
||||
import { MAX_DOCUMENT_UPLOAD_BYTES } from "@/lib/documents/limits";
|
||||
import { PhotoMetadataError, PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -28,7 +30,42 @@ import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);
|
||||
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
const TOO_LARGE_MESSAGE = "Datei zu groß (max. 10 MB)";
|
||||
|
||||
function isImage(file: File): boolean {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
/** "Beute 3.png" -> "Beute 3.jpg": a re-encoded image is always a JPEG. */
|
||||
function jpegName(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
return `${dot > 0 ? fileName.slice(0, dot) : fileName}.jpg`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Images are re-encoded first (preparePhoto): what reaches the server never
|
||||
* carries the camera's EXIF/GPS - an apiary's position is theft-relevant.
|
||||
* PDFs go up unchanged.
|
||||
*/
|
||||
async function uploadFile(
|
||||
entityType: DocumentEntityType,
|
||||
entityId: string,
|
||||
file: File,
|
||||
): Promise<DocumentSummary> {
|
||||
if (!isImage(file)) {
|
||||
return uploadDocument(entityType, entityId, file);
|
||||
}
|
||||
const photo = await preparePhoto(file);
|
||||
return uploadDocument(entityType, entityId, photo, { fileName: jpegName(file.name) });
|
||||
}
|
||||
|
||||
function uploadErrorMessage(error: unknown): string {
|
||||
if (error instanceof PhotoTooLargeError) return "Foto zu groß (max. 10 MB)";
|
||||
if (error instanceof PhotoMetadataError) {
|
||||
return "Foto wurde nicht hochgeladen: Die Standortdaten ließen sich nicht entfernen.";
|
||||
}
|
||||
return "Datei konnte nicht hochgeladen werden";
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -60,6 +97,9 @@ function RenameDocumentDialog({ document }: { document: DocumentSummary }) {
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// The dialog sits in a portal, but React still bubbles the submit up the
|
||||
// component tree: it must not also submit a form the card is placed in.
|
||||
event.stopPropagation();
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
@@ -127,12 +167,12 @@ export function DocumentUploadCard({
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadDocument(entityType, entityId, file),
|
||||
mutationFn: (file: File) => uploadFile(entityType, entityId, file),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
toast.success("Datei hochgeladen");
|
||||
},
|
||||
onError: () => toast.error("Datei konnte nicht hochgeladen werden"),
|
||||
onError: (error) => toast.error(uploadErrorMessage(error)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -152,8 +192,10 @@ export function DocumentUploadCard({
|
||||
toast.error("Nicht unterstützter Dateityp (nur JPEG, PNG, WebP, PDF)");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
toast.error("Datei zu groß (max. 10 MB)");
|
||||
// An image may shrink below the limit when it is re-encoded; preparePhoto
|
||||
// checks the result.
|
||||
if (!isImage(file) && file.size > MAX_DOCUMENT_UPLOAD_BYTES) {
|
||||
toast.error(TOO_LARGE_MESSAGE);
|
||||
return;
|
||||
}
|
||||
uploadMutation.mutate(file);
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ChoiceOption<T extends ChoiceValue> {
|
||||
* the visible label (voice control users say what they see).
|
||||
*/
|
||||
srSuffix?: string;
|
||||
/** Replaces the default selected look, e.g. a tone colour of a rating step. */
|
||||
selectedClassName?: string;
|
||||
}
|
||||
|
||||
/** Distinct per value and type: "null" (text) and null must not collide. */
|
||||
@@ -131,6 +133,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
selected &&
|
||||
!isUnanswered &&
|
||||
"border-primary bg-primary font-medium text-primary-foreground",
|
||||
selected && option.selectedClassName,
|
||||
optionClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FormBackLink } from "./form-back-link";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}));
|
||||
|
||||
describe("FormBackLink", () => {
|
||||
beforeEach(() => push.mockReset());
|
||||
|
||||
it("is a plain link when nothing was typed", () => {
|
||||
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty={false} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: /Zu den Völkern/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/colonies",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before leaving and flushes the draft", async () => {
|
||||
const flush = vi.fn();
|
||||
render(
|
||||
<FormBackLink href="/colonies" label="Zu den Völkern" dirty draft={{ flush }} />,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
|
||||
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByText(/Deine Eingaben bleiben als Entwurf auf diesem Gerät\./),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verlassen" }));
|
||||
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
expect(push).toHaveBeenCalledWith("/colonies");
|
||||
});
|
||||
|
||||
it("stays on the form with Weiter bearbeiten", async () => {
|
||||
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty />);
|
||||
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Weiter bearbeiten" }));
|
||||
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns that input is lost when there is no draft", async () => {
|
||||
render(<FormBackLink href="/colonies" label="Zu den Völkern" dirty />);
|
||||
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zu den Völkern/ }));
|
||||
|
||||
expect(await screen.findByText(/Deine Eingaben gehen verloren\./)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { MouseEvent, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export interface FormBackLinkProps {
|
||||
href: string;
|
||||
label: string;
|
||||
/** Something was typed in: leaving needs a confirmation. */
|
||||
dirty: boolean;
|
||||
/** The form's draft; flushed so leaving does not race the autosave delay. */
|
||||
draft?: { flush: () => void };
|
||||
}
|
||||
|
||||
const NOT_PLAIN_CLICK = (event: MouseEvent) =>
|
||||
event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||
|
||||
/**
|
||||
* „Zurück" above a new-form's header. A plain link while the form is
|
||||
* untouched; once something was typed it asks first, saying honestly whether
|
||||
* the input stays as a draft on this device or is lost.
|
||||
*/
|
||||
export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
function handleClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||
// Modified clicks open a new tab: the form here stays untouched.
|
||||
if (!dirty || NOT_PLAIN_CLICK(event)) return;
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function leave() {
|
||||
draft?.flush();
|
||||
setOpen(false);
|
||||
router.push(href);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className="-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Formular verlassen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{draft
|
||||
? "Deine Eingaben bleiben als Entwurf auf diesem Gerät."
|
||||
: "Deine Eingaben gehen verloren."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
Weiter bearbeiten
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={leave}>
|
||||
Verlassen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NumberStepper } from "./number-stepper";
|
||||
|
||||
function Harness({ initial = "" }: { initial?: string }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<NumberStepper
|
||||
id="mites"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
unitLabel="Milben"
|
||||
decrementLabel="Eine Milbe weniger"
|
||||
incrementLabel="Eine Milbe mehr"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("NumberStepper", () => {
|
||||
it('„+" from empty gives 1', async () => {
|
||||
render(<Harness />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("1");
|
||||
});
|
||||
|
||||
it('„−" is disabled at 0', () => {
|
||||
render(<Harness initial="0" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Eine Milbe weniger/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('„−" is disabled while empty', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Eine Milbe weniger/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('typing 7 then „+" gives 8', async () => {
|
||||
render(<Harness />);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "7");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("8");
|
||||
});
|
||||
|
||||
it('„−" steps down from 8 to 7', async () => {
|
||||
render(<Harness initial="8" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe weniger/ }));
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("7");
|
||||
});
|
||||
|
||||
it("drops non-digits while typing", async () => {
|
||||
render(<Harness />);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "1a2");
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("12");
|
||||
});
|
||||
|
||||
it('„leeren" clears to empty and only shows with a value', async () => {
|
||||
render(<Harness />);
|
||||
expect(screen.queryByRole("button", { name: /leeren/ })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "5");
|
||||
await userEvent.click(screen.getByRole("button", { name: /leeren/ }));
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("moves focus to the input when „−“ turns disabled", async () => {
|
||||
render(<Harness initial="1" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe weniger/ }));
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("0");
|
||||
expect(screen.getByRole("textbox")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("moves focus to the input after „leeren“", async () => {
|
||||
render(<Harness initial="5" />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /leeren/ }));
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("caps the value so „+“ never produces huge numbers", async () => {
|
||||
render(<Harness initial="9999" />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "4");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("9999");
|
||||
});
|
||||
|
||||
it("buttons are named and 44 px", () => {
|
||||
render(<Harness initial="3" />);
|
||||
|
||||
for (const name of [/Eine Milbe weniger/, /Eine Milbe mehr/, /leeren/]) {
|
||||
expect(screen.getByRole("button", { name })).toHaveClass("min-h-11");
|
||||
}
|
||||
expect(screen.getByRole("textbox")).toHaveAccessibleDescription("Milben");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface NumberStepperProps {
|
||||
id: string;
|
||||
/** "" = not counted. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
min?: number;
|
||||
step?: number;
|
||||
/** Visible unit next to the field, e.g. "Milben"; the name comes from the Field label. */
|
||||
unitLabel: string;
|
||||
decrementLabel: string;
|
||||
incrementLabel: string;
|
||||
describedBy?: string;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
const STEP_BUTTON_CLASS =
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold outline-none motion-safe:transition-colors hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
const MAX_DIGITS = 4;
|
||||
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
|
||||
|
||||
function digitsOnly(text: string): string {
|
||||
return text.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A count with big plus and minus buttons for gloved fingers; the field stays
|
||||
* typable. Whole counts only: digits only (no negatives or decimals), at most
|
||||
* 4 digits. Empty means "not counted": "+" then starts at min + step, "−" is
|
||||
* off, and "leeren" goes back to empty (a 0 would read as "counted: none").
|
||||
*/
|
||||
export function NumberStepper({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
step = 1,
|
||||
unitLabel,
|
||||
decrementLabel,
|
||||
incrementLabel,
|
||||
describedBy,
|
||||
invalid,
|
||||
}: NumberStepperProps) {
|
||||
const unitId = `${id}-unit`;
|
||||
const current = value === "" ? null : Number(value);
|
||||
const canDecrement = current !== null && current - step >= min;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function increment() {
|
||||
onChange(String(Math.min(MAX_VALUE, current === null ? min + step : current + step)));
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
if (!canDecrement) return;
|
||||
const next = current - step;
|
||||
onChange(String(next));
|
||||
// A button that turns disabled would drop keyboard focus to the page.
|
||||
if (next - step < min) inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
onChange("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleInput(event: ChangeEvent<HTMLInputElement>) {
|
||||
onChange(digitsOnly(event.target.value));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrement}
|
||||
disabled={!canDecrement}
|
||||
className={STEP_BUTTON_CLASS}
|
||||
>
|
||||
−<span className="sr-only"> {decrementLabel}</span>
|
||||
</button>
|
||||
<Input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
maxLength={MAX_DIGITS}
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={handleInput}
|
||||
aria-describedby={[describedBy, unitId].filter(Boolean).join(" ")}
|
||||
aria-invalid={invalid || undefined}
|
||||
className="w-20 text-center text-lg font-semibold sm:h-11"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increment}
|
||||
className={STEP_BUTTON_CLASS}
|
||||
>
|
||||
+<span className="sr-only"> {incrementLabel}</span>
|
||||
</button>
|
||||
<span id={unitId} className="text-sm text-muted-foreground">
|
||||
{unitLabel}
|
||||
</span>
|
||||
</div>
|
||||
{value !== "" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="min-h-11 rounded-lg px-2 text-sm text-muted-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
leeren{" "}
|
||||
<span className="sr-only">– {unitLabel}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PreviousValueHint } from "./previous-value-hint";
|
||||
|
||||
describe("PreviousValueHint", () => {
|
||||
it("announces and takes the value over", async () => {
|
||||
const onTakeOver = vi.fn();
|
||||
render(
|
||||
<PreviousValueHint fieldLabel="Königin gesehen" valueLabel="Ja" onTakeOver={onTakeOver} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Letztes Mal: Ja/)).toBeInTheDocument();
|
||||
const button = screen.getByRole("button", { name: /übernehmen/ });
|
||||
expect(button).toHaveAccessibleName("übernehmen – Königin gesehen");
|
||||
expect(button).toHaveClass("min-h-11");
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(onTakeOver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PreviousValueHintProps {
|
||||
/** Name of the field, spoken after "übernehmen" so the button is unambiguous. */
|
||||
fieldLabel: string;
|
||||
/** The earlier answer in words, e.g. "Ja" or "4 von 5". */
|
||||
valueLabel: string;
|
||||
onTakeOver: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Letztes Mal: Ja · übernehmen". Observations are never prefilled - what
|
||||
* the beekeeper did not look at today must not read as seen. The last value
|
||||
* is only shown, and one tap takes it over.
|
||||
*/
|
||||
export function PreviousValueHint({
|
||||
fieldLabel,
|
||||
valueLabel,
|
||||
onTakeOver,
|
||||
className,
|
||||
}: PreviousValueHintProps) {
|
||||
return (
|
||||
<p className={cn("flex flex-wrap items-center gap-x-2 text-sm text-muted-foreground", className)}>
|
||||
<span>
|
||||
Letztes Mal: {valueLabel} <span aria-hidden="true">·</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTakeOver}
|
||||
className="min-h-11 rounded-lg px-2 font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
übernehmen{" "}
|
||||
<span className="sr-only">– {fieldLabel}</span>
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { RatingScale } from "./rating-scale";
|
||||
import type { RatingDirection } from "@/lib/forms/rating";
|
||||
|
||||
function Harness({
|
||||
initial,
|
||||
direction,
|
||||
onChange,
|
||||
}: {
|
||||
initial: number | null;
|
||||
direction?: RatingDirection;
|
||||
onChange?: (value: number | null) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<RatingScale
|
||||
id="mood"
|
||||
value={value}
|
||||
direction={direction}
|
||||
lowLabel="ruhig"
|
||||
highLabel="stark"
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RatingScale", () => {
|
||||
it("lower-is-better shows stored 5 as display 1 on the left", () => {
|
||||
render(<Harness initial={5} direction="lower-is-better" />);
|
||||
|
||||
const radios = within(screen.getByRole("radiogroup")).getAllByRole("radio");
|
||||
expect(radios[0]).toHaveAttribute("aria-checked", "true");
|
||||
expect(radios[0]).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("tapping display 5 stores 1", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness initial={null} direction="lower-is-better" onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getAllByRole("radio")[4]);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
|
||||
it("keeps stored values unchanged for higher-is-better", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness initial={null} onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getAllByRole("radio")[3]);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(4);
|
||||
});
|
||||
|
||||
it("tapping the chosen value again clears to null", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness initial={3} onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getAllByRole("radio")[2]);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("the selected option carries the tone of its display value", () => {
|
||||
const { unmount } = render(<Harness initial={5} direction="lower-is-better" />);
|
||||
expect(screen.getAllByRole("radio")[0]).toHaveClass("bg-condition-critical");
|
||||
unmount();
|
||||
|
||||
render(<Harness initial={5} />);
|
||||
expect(screen.getAllByRole("radio")[4]).toHaveClass("bg-condition-excellent");
|
||||
});
|
||||
|
||||
it("lower-is-better swaps the stored end words so the bad end stays left", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness initial={null} direction="lower-is-better" onChange={onChange} />);
|
||||
|
||||
expect(screen.getByText("1 = stark")).toBeInTheDocument();
|
||||
expect(screen.getByText("5 = ruhig")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getAllByRole("radio")[0]);
|
||||
expect(onChange).toHaveBeenLastCalledWith(5);
|
||||
});
|
||||
|
||||
it("higher-is-better labels the stored ends as given", () => {
|
||||
render(<Harness initial={null} />);
|
||||
|
||||
expect(screen.getByText("1 = ruhig")).toBeInTheDocument();
|
||||
expect(screen.getByText("5 = stark")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RATING_TONE_CLASS,
|
||||
toDisplayRating,
|
||||
toStoredRating,
|
||||
type RatingDirection,
|
||||
} from "@/lib/forms/rating";
|
||||
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
|
||||
|
||||
const RATING_OPTIONS: readonly ChoiceOption<number | null>[] = [1, 2, 3, 4, 5].map(
|
||||
(rating) => ({ value: rating, label: String(rating), srSuffix: "von 5" }),
|
||||
);
|
||||
const DISPLAY_STEPS = [1, 2, 3, 4, 5] as const;
|
||||
|
||||
const RATING_OPTIONS: readonly ChoiceOption<number | null>[] = DISPLAY_STEPS.map((step) => ({
|
||||
value: step,
|
||||
label: String(step),
|
||||
srSuffix: "von 5",
|
||||
selectedClassName: RATING_TONE_CLASS[step],
|
||||
}));
|
||||
|
||||
interface RatingScaleProps {
|
||||
id: string;
|
||||
/** Stored value; with `direction` it is mirrored for display only. */
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
/** Words for the ends, e.g. "stechlustig" / "sehr sanft". */
|
||||
/**
|
||||
* Words for the STORED ends (lowLabel = stored 1, highLabel = stored 5).
|
||||
* With `direction="lower-is-better"` they are shown swapped, so the bad end
|
||||
* stays on the left.
|
||||
*/
|
||||
lowLabel: string;
|
||||
highLabel: string;
|
||||
/** Which stored end is good; the good end is always shown on the right. */
|
||||
direction?: RatingDirection;
|
||||
describedBy?: string;
|
||||
/** Id of the visible label, so the group is announced by name. */
|
||||
labelledBy?: string;
|
||||
@@ -20,8 +38,9 @@ interface RatingScaleProps {
|
||||
|
||||
/**
|
||||
* 1-5 as five large buttons instead of a number field: one tap with gloves,
|
||||
* no keyboard, and the meaning of both ends stays on screen. Tapping the
|
||||
* chosen value again clears it.
|
||||
* no keyboard, and the meaning of both ends stays on screen. Left is always
|
||||
* the bad end, right the good one, and a chosen step takes the tone of its
|
||||
* displayed value. Tapping the chosen value again clears it.
|
||||
*/
|
||||
export function RatingScale({
|
||||
id,
|
||||
@@ -29,9 +48,12 @@ export function RatingScale({
|
||||
onChange,
|
||||
lowLabel,
|
||||
highLabel,
|
||||
direction = "higher-is-better",
|
||||
describedBy,
|
||||
labelledBy,
|
||||
}: RatingScaleProps) {
|
||||
const [leftLabel, rightLabel] =
|
||||
direction === "lower-is-better" ? [highLabel, lowLabel] : [lowLabel, highLabel];
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<ChoiceGroup
|
||||
@@ -39,15 +61,15 @@ export function RatingScale({
|
||||
labelledBy={labelledBy}
|
||||
describedBy={describedBy}
|
||||
options={RATING_OPTIONS}
|
||||
value={value}
|
||||
value={toDisplayRating(value, direction)}
|
||||
clearValue={null}
|
||||
onChange={onChange}
|
||||
onChange={(display) => onChange(toStoredRating(display, direction))}
|
||||
className="grid-cols-5"
|
||||
optionClassName="text-base font-semibold"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>1 = {lowLabel}</span>
|
||||
<span>5 = {highLabel}</span>
|
||||
<span>1 = {leftLabel}</span>
|
||||
<span>5 = {rightLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,4 +57,31 @@ describe("TriStateField", () => {
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Nein" })).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("shows „Letztes Mal: Ja · übernehmen“ and takes it over", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TriStateField
|
||||
label="Königin gesehen"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
previous={{ valueLabel: "Ja", onTakeOver: () => onChange(true) }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Letztes Mal: Ja/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Nicht geprüft" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /übernehmen/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("shows no hint without a previous value", () => {
|
||||
render(<Harness initial={null} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByText(/Letztes Mal/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useId } from "react";
|
||||
import { ChoiceGroup, type ChoiceOption } from "./choice-group";
|
||||
import { PreviousValueHint } from "./previous-value-hint";
|
||||
|
||||
export const TRI_STATE_OPTIONS: readonly ChoiceOption<boolean | null>[] = [
|
||||
{ value: true, label: "Ja" },
|
||||
@@ -13,6 +14,8 @@ interface TriStateFieldProps {
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
onChange: (value: boolean | null) => void;
|
||||
/** Last time's answer, offered for one-tap take-over - never prefilled. */
|
||||
previous?: { valueLabel: string; onTakeOver: () => void } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +23,7 @@ interface TriStateFieldProps {
|
||||
* "Nicht geprüft" (null), so only what the beekeeper actually looked at is
|
||||
* saved as a finding - an untouched "no" would otherwise read as "seen: none".
|
||||
*/
|
||||
export function TriStateField({ label, value, onChange }: TriStateFieldProps) {
|
||||
export function TriStateField({ label, value, onChange, previous }: TriStateFieldProps) {
|
||||
const labelId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -34,6 +37,13 @@ export function TriStateField({ label, value, onChange }: TriStateFieldProps) {
|
||||
onChange={onChange}
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
{previous ? (
|
||||
<PreviousValueHint
|
||||
fieldLabel={label}
|
||||
valueLabel={previous.valueLabel}
|
||||
onTakeOver={previous.onTakeOver}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
/** A tappable row in a nav sheet: 56px high for gloved or hurried thumbs. */
|
||||
export const NAV_SHEET_ROW_CLASS =
|
||||
"flex min-h-14 items-center gap-3 rounded-xl px-3 text-base font-medium focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none";
|
||||
|
||||
interface NavSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The `SheetTrigger` that opens the sheet. */
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
/** Extra classes for the sheet panel. */
|
||||
className?: string;
|
||||
/** Where focus goes on close; by default back to the trigger. */
|
||||
finalFocus?: ComponentProps<typeof SheetContent>["finalFocus"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared bottom sheet of „Mehr", „+ Neu" and the offline queue: a title,
|
||||
* a German close button and a scroll area that clears the iOS home indicator.
|
||||
*/
|
||||
export function NavSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
title,
|
||||
className,
|
||||
finalFocus,
|
||||
children,
|
||||
}: NavSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
{trigger}
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
// The built-in close button is English; ours is German and 44px high.
|
||||
showCloseButton={false}
|
||||
finalFocus={finalFocus}
|
||||
className={[
|
||||
"max-h-[85dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom)",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<SheetHeader className="flex-row items-center justify-between">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetClose
|
||||
render={<Button variant="ghost" size="sm" className="min-h-11" />}
|
||||
>
|
||||
Schließen
|
||||
</SheetClose>
|
||||
</SheetHeader>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { CloudOff, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { countAllPending } from "@/lib/offline/db";
|
||||
import { syncPendingInspections } from "@/lib/offline/sync";
|
||||
|
||||
const PENDING_QUERY_KEY = ["offline-pending-entries"];
|
||||
|
||||
export function OfflineSyncBadge() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: pending = 0 } = useQuery({
|
||||
queryKey: PENDING_QUERY_KEY,
|
||||
queryFn: countAllPending,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: syncPendingInspections,
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: PENDING_QUERY_KEY });
|
||||
if (result.synced > 0) {
|
||||
toast.success(`${result.synced} Eintrag/Einträge synchronisiert`);
|
||||
}
|
||||
if (result.failed > 0) {
|
||||
toast.error(
|
||||
`${result.failed} Eintrag/Einträge konnten nicht synchronisiert werden und bleiben in der Warteschlange`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: () =>
|
||||
toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleOnline() {
|
||||
syncMutation.mutate();
|
||||
}
|
||||
window.addEventListener("online", handleOnline);
|
||||
if (navigator.onLine) {
|
||||
syncMutation.mutate();
|
||||
}
|
||||
return () => window.removeEventListener("online", handleOnline);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (pending === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="warning"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer"
|
||||
title="Ausstehende Einträge synchronisieren"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
syncMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{syncMutation.isPending ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<CloudOff className="size-3.5" />
|
||||
)}
|
||||
{pending} ausstehend
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useRef } from "react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { discardQueueItem, listQueue, summarizeQueue, type QueueItem } from "@/lib/offline/db";
|
||||
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OfflineBand } from "./offline-band";
|
||||
|
||||
vi.mock("@/lib/offline/db", () => ({
|
||||
summarizeQueue: vi.fn(),
|
||||
listQueue: vi.fn(),
|
||||
discardQueueItem: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn(), resumeSync: vi.fn() }));
|
||||
|
||||
const NOTHING_SENT = { synced: 0, failed: 0, skippedForeign: 0 };
|
||||
const REJECTED: QueueItem = {
|
||||
store: "pending-inspections",
|
||||
id: 7,
|
||||
label: "Durchsicht",
|
||||
queuedAt: "2026-10-13T08:15:00.000Z",
|
||||
lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" },
|
||||
};
|
||||
const WAITING: QueueItem = {
|
||||
store: "pending-feedings",
|
||||
id: 3,
|
||||
label: "Fütterung",
|
||||
queuedAt: "2026-10-13T08:20:00.000Z",
|
||||
};
|
||||
|
||||
function setOnLine(value: boolean) {
|
||||
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(value);
|
||||
}
|
||||
|
||||
/** The band as the layout places it: above the page content wrapper. */
|
||||
function BandOnPage() {
|
||||
const pageContentRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<>
|
||||
<OfflineBand userId="user-1" pageContentRef={pageContentRef} />
|
||||
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
|
||||
Seiteninhalt
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBand() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<BandOnPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens the sheet from the rejected entries and discards the rejected one. */
|
||||
async function discardRejectedEntry(nextSummary: { own: number; failed: number; foreign: number }) {
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "1 Eintrag abgelehnt – ansehen" }),
|
||||
);
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
vi.mocked(summarizeQueue).mockResolvedValue(nextSummary);
|
||||
vi.mocked(listQueue).mockResolvedValue(nextSummary.own > 0 ? [WAITING] : []);
|
||||
await userEvent.click(
|
||||
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
|
||||
);
|
||||
const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
|
||||
return sheet;
|
||||
}
|
||||
|
||||
describe("OfflineBand", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(summarizeQueue).mockReset();
|
||||
vi.mocked(listQueue).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT);
|
||||
vi.mocked(resumeSync).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("announces „Offline – 2 Einträge warten\" in a status region", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Offline – 2 Einträge warten"),
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
|
||||
expect(summarizeQueue).toHaveBeenCalledWith("user-1");
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says „1 Eintrag wartet\" for a single entry", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Offline – 1 Eintrag wartet"),
|
||||
);
|
||||
});
|
||||
|
||||
it("tells that input is kept while offline with an empty queue", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent(
|
||||
"Offline – Eingaben werden gespeichert und später gesendet",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an empty status region online with an empty queue", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 3 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() => expect(summarizeQueue).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
// The live region must exist before its first message, or that message is not announced.
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveAttribute("aria-live", "polite");
|
||||
expect(status).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("„Jetzt senden\" starts syncPendingEntries", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
|
||||
renderBand();
|
||||
const button = await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
|
||||
// Online on mount the band already tries once by itself.
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(1));
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(2));
|
||||
expect(syncPendingEntries).toHaveBeenLastCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("its button is at least 44 px", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "1 Eintrag wartet – Jetzt senden" }),
|
||||
).toHaveClass("min-h-11");
|
||||
expect(screen.getByRole("button", { name: "1 Eintrag abgelehnt – ansehen" })).toHaveClass(
|
||||
"min-h-11",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the queue sheet from the rejected entries", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 2, foreign: 0 });
|
||||
renderBand();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "2 Einträge abgelehnt – ansehen" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
|
||||
expect(listQueue).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("closes the sheet once nothing is left and keeps focus in the band while it has content", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
|
||||
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
|
||||
renderBand();
|
||||
|
||||
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
|
||||
);
|
||||
// Offline, the band still says so - focus lands on that message.
|
||||
await waitFor(() => expect(screen.getByRole("status")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("moves focus to the page content when the band has nothing left to show", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
|
||||
vi.mocked(listQueue).mockResolvedValue([REJECTED]);
|
||||
renderBand();
|
||||
|
||||
await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("page-content")).toHaveFocus());
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("keeps the sheet open with the waiting entries after the last rejected one is discarded", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 });
|
||||
vi.mocked(listQueue).mockResolvedValue([REJECTED, WAITING]);
|
||||
renderBand();
|
||||
|
||||
const sheet = await discardRejectedEntry({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: /abgelehnt – ansehen/ })).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(within(sheet).getByText("Fütterung")).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(sheet).getByRole("button", { name: "Schließen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(),
|
||||
);
|
||||
// The trigger is gone; focus goes to the band, which still shows the
|
||||
// waiting entry (Base UI picks its first focusable element, the send button).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toContainElement(document.activeElement as HTMLElement),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "1 Eintrag wartet – Jetzt senden" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("opens sync for the signed-in user before its first automatic sync", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
expect(resumeSync).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(resumeSync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("sends by itself as soon as the connection is back", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderBand();
|
||||
await screen.findByText("Offline – 1 Eintrag wartet");
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
|
||||
setOnLine(true);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("online"));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { RefObject, useEffect, useRef, useState } from "react";
|
||||
import { AlertTriangle, CloudOff, Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { QueueSummary, summarizeQueue } from "@/lib/offline/db";
|
||||
import { entriesRejected, entriesSynced, entriesWaiting } from "@/lib/offline/queue-texts";
|
||||
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { PendingQueueSheet } from "./pending-queue-sheet";
|
||||
|
||||
const COUNT_INTERVAL_MS = 15_000;
|
||||
const EMPTY_QUEUE: QueueSummary = { own: 0, failed: 0, foreign: 0 };
|
||||
// Both auto-sync triggers can fire together; they share one sync run
|
||||
// (syncPendingEntries), and one toast id keeps it to one message.
|
||||
const SYNC_TOAST_ID = "offline-sync";
|
||||
// Wraps on narrow phones instead of overflowing; never below 44 px.
|
||||
const BAND_BUTTON_CLASS = "h-auto min-h-11 whitespace-normal text-left";
|
||||
|
||||
interface OfflineBandProps {
|
||||
userId: string;
|
||||
/** Focus target when the queue sheet closes and the band shows nothing. */
|
||||
pageContentRef: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under the header that says whether input is only stored on this
|
||||
* device, how much still waits to be sent and what the server rejected.
|
||||
* Sends by itself as soon as the connection is back.
|
||||
*/
|
||||
export function OfflineBand({ userId, pageContentRef }: OfflineBandProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const connectivity = useConnectivity();
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
const { data: queue = EMPTY_QUEUE } = useQuery({
|
||||
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "summary"],
|
||||
queryFn: () => summarizeQueue(userId),
|
||||
refetchInterval: COUNT_INTERVAL_MS,
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
|
||||
const sync = useMutation({
|
||||
mutationFn: () => syncPendingEntries(userId),
|
||||
networkMode: "always",
|
||||
onSuccess: (result) => {
|
||||
if (result.synced > 0) {
|
||||
toast.success(entriesSynced(result.synced), { id: SYNC_TOAST_ID });
|
||||
// New records on the server: every list and count may be stale now.
|
||||
void queryClient.invalidateQueries();
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
},
|
||||
onError: () =>
|
||||
toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht", {
|
||||
id: SYNC_TOAST_ID,
|
||||
}),
|
||||
});
|
||||
const { mutate: startSync } = sync;
|
||||
|
||||
// The band only exists for a signed-in user, and the auth context sets that
|
||||
// user's access token before the user: the moment to reopen sync after a
|
||||
// logout's cancelSync(). Declared first, so it runs before the sync below.
|
||||
useEffect(() => {
|
||||
resumeSync();
|
||||
}, [userId]);
|
||||
|
||||
// Mount, and every switch back to online (a request reached the server again).
|
||||
useEffect(() => {
|
||||
if (connectivity === "online") {
|
||||
startSync();
|
||||
}
|
||||
}, [connectivity, startSync, userId]);
|
||||
|
||||
// The browser's own `online` event: try even while the last request failed.
|
||||
useEffect(() => {
|
||||
const handleOnline = () => startSync();
|
||||
window.addEventListener("online", handleOnline);
|
||||
return () => window.removeEventListener("online", handleOnline);
|
||||
}, [startSync]);
|
||||
|
||||
// The sheet stays open while the user still has entries in it (waiting
|
||||
// ones too, after the last rejected one was discarded), and closes once
|
||||
// there is nothing left to show.
|
||||
const hasRejected = queue.failed > 0;
|
||||
if (sheetOpen && queue.own === 0 && !hasRejected) {
|
||||
setSheetOpen(false);
|
||||
}
|
||||
|
||||
const isOffline = connectivity === "offline";
|
||||
const hasContent = isOffline || queue.own > 0 || hasRejected;
|
||||
|
||||
// Back to the trigger while it exists; otherwise to the band's message, or
|
||||
// to the page content when the band shows nothing.
|
||||
function sheetFinalFocus(): true | HTMLElement | null {
|
||||
if (hasRejected) {
|
||||
return true;
|
||||
}
|
||||
return hasContent ? statusRef.current : pageContentRef.current;
|
||||
}
|
||||
|
||||
// The live region is always there, so its first message is announced too.
|
||||
return (
|
||||
<div
|
||||
ref={statusRef}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
hasContent &&
|
||||
"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6",
|
||||
hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"),
|
||||
)}
|
||||
>
|
||||
{isOffline ? (
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<CloudOff className="size-4 shrink-0" aria-hidden />
|
||||
{queue.own > 0
|
||||
? `Offline – ${entriesWaiting(queue.own)}`
|
||||
: "Offline – Eingaben werden gespeichert und später gesendet"}
|
||||
</p>
|
||||
) : null}
|
||||
{!isOffline && queue.own > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className={BAND_BUTTON_CLASS}
|
||||
disabled={sync.isPending}
|
||||
onClick={() => startSync()}
|
||||
>
|
||||
<Send aria-hidden />
|
||||
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
|
||||
</Button>
|
||||
) : null}
|
||||
<PendingQueueSheet
|
||||
userId={userId}
|
||||
foreignCount={queue.foreign}
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
finalFocus={sheetFinalFocus}
|
||||
trigger={
|
||||
hasRejected ? (
|
||||
<SheetTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
className={BAND_BUTTON_CLASS}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AlertTriangle aria-hidden />
|
||||
{`${entriesRejected(queue.failed)} – ansehen`}
|
||||
</SheetTrigger>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { discardQueueItem, listQueue, type QueueItem, type StoredPhoto } from "@/lib/offline/db";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
|
||||
import { PendingQueueSheet } from "./pending-queue-sheet";
|
||||
|
||||
vi.mock("@/lib/offline/db", () => ({
|
||||
listQueue: vi.fn(),
|
||||
discardQueueItem: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/photos/store")>()),
|
||||
listPendingPhotos: vi.fn(),
|
||||
removePhoto: vi.fn(),
|
||||
resetPhotoError: vi.fn(),
|
||||
}));
|
||||
|
||||
const REJECTED_PHOTO: StoredPhoto = {
|
||||
id: "photo-1",
|
||||
sessionId: "session-1",
|
||||
ownerUserId: "user-1",
|
||||
blob: new Blob(["jpeg"], { type: "image/jpeg" }),
|
||||
fileName: "durchsicht-2026-10-13-1.jpg",
|
||||
createdAt: "2026-10-13T08:16:00.000Z",
|
||||
state: "ready",
|
||||
inspectionId: "srv-1",
|
||||
uploadIdempotencyKey: "upload-key-0000000001",
|
||||
lastError: { status: 413, message: "Datei zu groß", at: "2026-10-13T09:00:00.000Z" },
|
||||
};
|
||||
const WAITING_PHOTO: StoredPhoto = {
|
||||
...REJECTED_PHOTO,
|
||||
id: "photo-2",
|
||||
createdAt: "2026-10-13T08:17:00.000Z",
|
||||
lastError: undefined,
|
||||
};
|
||||
|
||||
const REJECTED: QueueItem = {
|
||||
store: "pending-inspections",
|
||||
id: 7,
|
||||
label: "Durchsicht",
|
||||
queuedAt: "2026-10-13T08:15:00.000Z",
|
||||
lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" },
|
||||
};
|
||||
const WAITING: QueueItem = {
|
||||
store: "pending-feedings",
|
||||
id: 3,
|
||||
label: "Fütterung",
|
||||
queuedAt: "2026-10-13T08:20:00.000Z",
|
||||
};
|
||||
|
||||
/** How the sheet names an entry's capture time (local time zone, with seconds). */
|
||||
function shown(item: QueueItem): string {
|
||||
return new Date(item.queuedAt).toLocaleString("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
function renderSheet(foreignCount = 0) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PendingQueueSheet
|
||||
userId="user-1"
|
||||
foreignCount={foreignCount}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
trigger={null}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PendingQueueSheet", () => {
|
||||
beforeAll(() => {
|
||||
// jsdom has no object URLs; the photo thumbnails only need a string.
|
||||
URL.createObjectURL = vi.fn(() => "blob:photo");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]);
|
||||
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(removePhoto).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(syncPendingEntries)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
it("shows „+ 2 Fotos“ at a queued inspection and discards them with it", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([{ ...REJECTED, photoCount: 2 }]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(await within(sheet).findByText("+ 2 Fotos")).toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
within(sheet).getByRole("button", { name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}` }),
|
||||
);
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
|
||||
});
|
||||
expect(confirm).toHaveTextContent("Der Eintrag und seine 2 Fotos werden von diesem Gerät gelöscht");
|
||||
});
|
||||
|
||||
it("lets a rejected photo be sent again", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([]);
|
||||
vi.mocked(listPendingPhotos).mockResolvedValue([REJECTED_PHOTO, WAITING_PHOTO]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(await within(sheet).findByText("Abgelehnt: Datei zu groß")).toBeInTheDocument();
|
||||
expect(within(sheet).getAllByText("Foto zur Durchsicht")).toHaveLength(2);
|
||||
expect(listPendingPhotos).toHaveBeenCalledWith("user-1");
|
||||
// Only the rejected photo needs the user; the waiting one is sent by itself.
|
||||
const retry = within(sheet).getAllByRole("button", { name: /^Erneut senden: / });
|
||||
expect(retry).toHaveLength(1);
|
||||
expect(retry[0]).toHaveAccessibleName(
|
||||
`Erneut senden: Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`,
|
||||
);
|
||||
|
||||
await userEvent.click(retry[0]);
|
||||
|
||||
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
|
||||
expect(resetPhotoError).toHaveBeenCalledWith("photo-1");
|
||||
expect(vi.mocked(resetPhotoError).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(syncPendingEntries).mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("discards a photo after confirmation", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([]);
|
||||
vi.mocked(listPendingPhotos).mockResolvedValue([REJECTED_PHOTO]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
const name = `Foto vom ${shown({ ...REJECTED, queuedAt: REJECTED_PHOTO.createdAt })}`;
|
||||
|
||||
await userEvent.click(await within(sheet).findByRole("button", { name: `Verwerfen: ${name}` }));
|
||||
const confirm = await screen.findByRole("dialog", { name: `${name} verwerfen?` });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
await waitFor(() => expect(removePhoto).toHaveBeenCalledWith("photo-1"));
|
||||
expect(within(sheet).queryByText("Von dir wartet nichts mehr.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists a rejected entry with the server message and can discard it after confirmation", async () => {
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(await within(sheet).findByText(/Volk V-03 ist beendet\./)).toBeInTheDocument();
|
||||
expect(within(sheet).getByText(/Abgelehnt/)).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Fütterung")).toBeInTheDocument();
|
||||
expect(listQueue).toHaveBeenCalledWith("user-1");
|
||||
|
||||
const discardRejected = within(sheet).getByRole("button", {
|
||||
name: `Verwerfen: Durchsicht vom ${shown(REJECTED)}`,
|
||||
});
|
||||
await userEvent.click(discardRejected);
|
||||
expect(discardQueueItem).not.toHaveBeenCalled();
|
||||
|
||||
const confirm = await screen.findByRole("dialog", {
|
||||
name: `Durchsicht vom ${shown(REJECTED)} verwerfen?`,
|
||||
});
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
|
||||
});
|
||||
|
||||
it("names every discard button after its entry, starting with the visible word", async () => {
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
const buttons = await within(sheet).findAllByRole("button", { name: /^Verwerfen: / });
|
||||
|
||||
expect(buttons.map((button) => button.getAttribute("aria-label"))).toEqual([
|
||||
`Verwerfen: Durchsicht vom ${shown(REJECTED)}`,
|
||||
`Verwerfen: Fütterung vom ${shown(WAITING)}`,
|
||||
]);
|
||||
buttons.forEach((button) => expect(button).toHaveTextContent(/^Verwerfen$/));
|
||||
});
|
||||
|
||||
it("tells apart two entries of the same type from the same minute", async () => {
|
||||
const at = (iso: string, id: number): QueueItem => ({
|
||||
store: "pending-inspections",
|
||||
id,
|
||||
label: "Durchsicht",
|
||||
queuedAt: iso,
|
||||
});
|
||||
vi.mocked(listQueue).mockResolvedValue([
|
||||
at("2026-10-13T08:15:05.000Z", 1),
|
||||
at("2026-10-13T08:15:40.000Z", 2),
|
||||
at("2026-10-13T08:15:40.000Z", 3),
|
||||
]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
const names = (await within(sheet).findAllByRole("button", { name: /^Verwerfen: / })).map(
|
||||
(button) => button.getAttribute("aria-label"),
|
||||
);
|
||||
|
||||
expect(new Set(names).size).toBe(3);
|
||||
expect(names[2]).toMatch(/\(2\)$/);
|
||||
await userEvent.click(within(sheet).getAllByRole("button", { name: /^Verwerfen: / })[2]);
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: `${names[2]?.replace("Verwerfen: ", "")} verwerfen?` }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, "1 Eintrag eines anderen Kontos wartet"],
|
||||
[2, "2 Einträge eines anderen Kontos warten"],
|
||||
])("shows %i entries of another account only as a count", async (count, text) => {
|
||||
vi.mocked(listQueue).mockResolvedValue([]);
|
||||
renderSheet(count);
|
||||
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(await within(sheet).findByText(text)).toBeInTheDocument();
|
||||
expect(within(sheet).queryByRole("button", { name: /Verwerfen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { AlertTriangle, Camera, Clock, RotateCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NavSheet } from "@/components/nav/nav-sheet";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { discardQueueItem, listQueue, type QueueError, type QueueItem } from "@/lib/offline/db";
|
||||
import { entriesSynced, foreignEntriesWaiting, photosAttached } from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { listPendingPhotos, removePhoto, resetPhotoError } from "@/lib/photos/store";
|
||||
|
||||
interface PendingQueueSheetProps {
|
||||
userId: string;
|
||||
/** Entries of another account on this device - shown only as a count. */
|
||||
foreignCount: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The `SheetTrigger` that opens the sheet, or null when opened elsewhere. */
|
||||
trigger: ReactNode;
|
||||
/** Where focus goes on close; by default back to the trigger. */
|
||||
finalFocus?: ComponentProps<typeof NavSheet>["finalFocus"];
|
||||
}
|
||||
|
||||
interface Named {
|
||||
label: string;
|
||||
queuedAt: string;
|
||||
}
|
||||
|
||||
const PHOTO_LABEL = "Foto";
|
||||
|
||||
function queuedAtLabel(queuedAt: string): string {
|
||||
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
/**
|
||||
* „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and
|
||||
* dialog. Seconds tell apart entries of the same minute; a running number
|
||||
* „(2)" those queued in the same second.
|
||||
*/
|
||||
function entryNames(items: readonly Named[]): string[] {
|
||||
const seen = new Map<string, number>();
|
||||
return items.map((item) => {
|
||||
const time = new Date(item.queuedAt).toLocaleString("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
});
|
||||
const name = `${item.label} vom ${time}`;
|
||||
const occurrence = (seen.get(name) ?? 0) + 1;
|
||||
seen.set(name, occurrence);
|
||||
return occurrence === 1 ? name : `${name} (${occurrence})`;
|
||||
});
|
||||
}
|
||||
|
||||
/** A queued inspection takes its photos along when it is discarded. */
|
||||
function discardDescription(item: QueueItem): string {
|
||||
if (!item.photoCount) {
|
||||
return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet.";
|
||||
}
|
||||
const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`;
|
||||
return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`;
|
||||
}
|
||||
|
||||
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
|
||||
function QueueStatus({ lastError }: { lastError?: QueueError }) {
|
||||
return lastError ? (
|
||||
<p className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<span>Abgelehnt: {lastError.message}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<span>Wartet auf Senden</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What this device still has to send for the signed-in user: each entry with
|
||||
* its state, a rejected one with the server's reason, and a way to discard it.
|
||||
* Photos whose inspection is already on the server are listed on their own;
|
||||
* a rejected one can be sent again.
|
||||
*/
|
||||
export function PendingQueueSheet({
|
||||
userId,
|
||||
foreignCount,
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
finalFocus,
|
||||
}: PendingQueueSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: items = [], isSuccess } = useQuery({
|
||||
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"],
|
||||
queryFn: () => listQueue(userId),
|
||||
enabled: open,
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
const { data: photos = [] } = useQuery({
|
||||
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"],
|
||||
queryFn: () => listPendingPhotos(userId),
|
||||
enabled: open,
|
||||
networkMode: "always",
|
||||
});
|
||||
|
||||
const discard = useMutation({
|
||||
mutationFn: (item: QueueItem) => discardQueueItem(item),
|
||||
networkMode: "always",
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
toast.success("Eintrag verworfen");
|
||||
},
|
||||
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
|
||||
});
|
||||
|
||||
const discardPhoto = useMutation({
|
||||
mutationFn: (photoId: string) => removePhoto(photoId),
|
||||
networkMode: "always",
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
toast.success("Foto verworfen");
|
||||
},
|
||||
onError: () => toast.error("Foto konnte nicht verworfen werden."),
|
||||
});
|
||||
|
||||
// „Erneut senden": clear the rejection, then a normal sync run sends it.
|
||||
const retryPhoto = useMutation({
|
||||
mutationFn: async (photoId: string) => {
|
||||
await resetPhotoError(photoId);
|
||||
return syncPendingEntries(userId);
|
||||
},
|
||||
networkMode: "always",
|
||||
onSuccess: (result) => {
|
||||
if (result.synced > 0) {
|
||||
toast.success(entriesSynced(result.synced));
|
||||
// New files on the server: document lists may be stale now.
|
||||
void queryClient.invalidateQueries();
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
},
|
||||
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
|
||||
});
|
||||
|
||||
const names = entryNames(items);
|
||||
const photoNames = entryNames(
|
||||
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
|
||||
);
|
||||
|
||||
return (
|
||||
<NavSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
trigger={trigger}
|
||||
title="Wartende Einträge"
|
||||
finalFocus={finalFocus}
|
||||
>
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
{isSuccess && items.length === 0 && photos.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={`${item.store}-${item.id}`}
|
||||
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{item.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Erfasst am {queuedAtLabel(item.queuedAt)}
|
||||
</p>
|
||||
{item.photoCount ? (
|
||||
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Camera className="size-4 shrink-0" aria-hidden />
|
||||
{photosAttached(item.photoCount)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
triggerLabel="Verwerfen"
|
||||
triggerAriaLabel={`Verwerfen: ${names[index]}`}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title={`${names[index]} verwerfen?`}
|
||||
description={discardDescription(item)}
|
||||
confirmLabel="Verwerfen"
|
||||
onConfirm={() => discard.mutate(item)}
|
||||
isPending={discard.isPending}
|
||||
/>
|
||||
</div>
|
||||
<QueueStatus lastError={item.lastError} />
|
||||
</li>
|
||||
))}
|
||||
{photos.map((photo, index) => (
|
||||
<li
|
||||
key={`photo-${photo.id}`}
|
||||
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<PhotoThumbnail
|
||||
blob={photo.blob}
|
||||
alt=""
|
||||
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">Foto zur Durchsicht</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aufgenommen am {queuedAtLabel(photo.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<QueueStatus lastError={photo.lastError} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{photo.lastError ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
aria-label={`Erneut senden: ${photoNames[index]}`}
|
||||
disabled={retryPhoto.isPending}
|
||||
onClick={() => retryPhoto.mutate(photo.id)}
|
||||
>
|
||||
<RotateCw aria-hidden />
|
||||
Erneut senden
|
||||
</Button>
|
||||
) : null}
|
||||
<ConfirmDialog
|
||||
triggerLabel="Verwerfen"
|
||||
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title={`${photoNames[index]} verwerfen?`}
|
||||
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
|
||||
confirmLabel="Verwerfen"
|
||||
onConfirm={() => discardPhoto.mutate(photo.id)}
|
||||
isPending={discardPhoto.isPending}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{foreignCount > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</NavSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { addDraftPhoto, listSessionPhotos } from "@/lib/photos/store";
|
||||
import { PhotoMetadataError, preparePhoto } from "@/lib/photos/reencode";
|
||||
import { PhotoCaptureField } from "./photo-capture-field";
|
||||
|
||||
let permissions = new Set<string>();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
|
||||
useCurrentUserId: () => "user-1",
|
||||
}));
|
||||
vi.mock("@/lib/photos/reencode", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/photos/reencode")>()),
|
||||
preparePhoto: vi.fn(),
|
||||
}));
|
||||
|
||||
const CLEAN_PHOTO = new Blob(["clean jpeg"], { type: "image/jpeg" });
|
||||
|
||||
function cameraFile(): File {
|
||||
return new File(["raw camera jpeg with gps"], "IMG_0001.jpg", { type: "image/jpeg" });
|
||||
}
|
||||
|
||||
function cameraInput(container: HTMLElement): HTMLInputElement {
|
||||
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
if (!input) throw new Error("no camera input");
|
||||
return input;
|
||||
}
|
||||
|
||||
let sessionId: string;
|
||||
const onChange = vi.fn();
|
||||
|
||||
function renderField() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PhotoCaptureField step={7} sessionId={sessionId} date="2026-10-13" onChange={onChange} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function storePhotos(count: number) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await addDraftPhoto({ sessionId, ownerUserId: "user-1", blob: CLEAN_PHOTO, date: "2026-10-13" });
|
||||
}
|
||||
}
|
||||
|
||||
describe("PhotoCaptureField", () => {
|
||||
beforeAll(() => {
|
||||
// jsdom has no object URLs; the thumbnails only need a string.
|
||||
URL.createObjectURL = vi.fn(() => "blob:photo");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Every test works on its own capture session.
|
||||
sessionId = crypto.randomUUID();
|
||||
permissions = new Set(["documents:upload"]);
|
||||
vi.mocked(preparePhoto).mockReset().mockResolvedValue(CLEAN_PHOTO);
|
||||
onChange.mockReset();
|
||||
});
|
||||
|
||||
it('input accepts image/* with capture="environment"', () => {
|
||||
const { container } = renderField();
|
||||
|
||||
const input = cameraInput(container);
|
||||
|
||||
expect(input).toHaveAttribute("accept", "image/*");
|
||||
expect(input).toHaveAttribute("capture", "environment");
|
||||
expect(screen.getByRole("button", { name: "Foto aufnehmen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden without documents:upload", () => {
|
||||
permissions = new Set();
|
||||
const { container } = renderField();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Foto aufnehmen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Fotos")).not.toBeInTheDocument();
|
||||
expect(container.querySelector('input[type="file"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("stores a taken photo right away, re-encoded, and shows it", async () => {
|
||||
const { container } = renderField();
|
||||
const file = cameraFile();
|
||||
|
||||
await userEvent.upload(cameraInput(container), file);
|
||||
|
||||
expect(await screen.findByRole("img", { name: "Foto 1" })).toBeInTheDocument();
|
||||
expect(preparePhoto).toHaveBeenCalledWith(file);
|
||||
const [stored] = await listSessionPhotos(sessionId);
|
||||
expect(stored).toMatchObject({
|
||||
ownerUserId: "user-1",
|
||||
state: "draft",
|
||||
fileName: "durchsicht-2026-10-13-1.jpg",
|
||||
});
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Foto 1 gespeichert");
|
||||
});
|
||||
|
||||
it("blocks a 6th photo with a message", async () => {
|
||||
await storePhotos(5);
|
||||
const { container } = renderField();
|
||||
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(5);
|
||||
const openCamera = vi.spyOn(cameraInput(container), "click");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Foto aufnehmen" }));
|
||||
|
||||
expect(openCamera).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Höchstens 5 Fotos je Durchsicht");
|
||||
// Even a file that still arrives is not kept.
|
||||
await userEvent.upload(cameraInput(container), cameraFile());
|
||||
expect(preparePhoto).not.toHaveBeenCalled();
|
||||
expect(await listSessionPhotos(sessionId)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("removing a photo deletes it from the store", async () => {
|
||||
await storePhotos(2);
|
||||
renderField();
|
||||
expect(await screen.findAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(2);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Entfernen: Foto 1" }));
|
||||
const confirm = await screen.findByRole("dialog", { name: "Foto 1 entfernen?" });
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Entfernen" }));
|
||||
|
||||
await waitFor(async () => expect(await listSessionPhotos(sessionId)).toHaveLength(1));
|
||||
await waitFor(() => expect(screen.getAllByRole("img", { name: /^Foto \d$/ })).toHaveLength(1));
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps nothing when the location data could not be removed, and says so", async () => {
|
||||
vi.mocked(preparePhoto).mockRejectedValue(new PhotoMetadataError());
|
||||
const { container } = renderField();
|
||||
|
||||
await userEvent.upload(cameraInput(container), cameraFile());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Foto wurde nicht gespeichert"),
|
||||
);
|
||||
expect(await listSessionPhotos(sessionId)).toEqual([]);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useCallback, useRef, useState } from "react";
|
||||
import { Camera } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { PhotoMetadataError, PhotoTooLargeError, preparePhoto } from "@/lib/photos/reencode";
|
||||
import {
|
||||
PHOTOS_PER_INSPECTION_MAX,
|
||||
addDraftPhoto,
|
||||
listSessionPhotos,
|
||||
removePhoto,
|
||||
} from "@/lib/photos/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
|
||||
/** Prefix of the queries that read one form's photos from this device. */
|
||||
export const PHOTO_SESSION_QUERY_KEY = ["photo-session"] as const;
|
||||
|
||||
const LIMIT_MESSAGE = `Höchstens ${PHOTOS_PER_INSPECTION_MAX} Fotos je Durchsicht. Entferne eines, um ein neues aufzunehmen.`;
|
||||
|
||||
interface Notice {
|
||||
text: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
const NO_NOTICE: Notice = { text: "", isError: false };
|
||||
|
||||
function captureError(error: unknown): Notice {
|
||||
if (error instanceof PhotoTooLargeError) {
|
||||
return { text: "Das Foto ist zu groß. Bitte noch einmal aufnehmen.", isError: true };
|
||||
}
|
||||
if (error instanceof PhotoMetadataError) {
|
||||
return {
|
||||
text: "Foto wurde nicht gespeichert: Die Standortdaten ließen sich nicht entfernen.",
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return { text: "Foto konnte nicht gespeichert werden.", isError: true };
|
||||
}
|
||||
|
||||
interface PhotoThumbnailProps {
|
||||
blob: Blob;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A photo stored on this device, shown from an object URL. */
|
||||
export function PhotoThumbnail({ blob, alt, className }: PhotoThumbnailProps) {
|
||||
// The URL lives exactly as long as the mounted image (ref cleanup), also
|
||||
// through StrictMode's unmount and remount.
|
||||
const showBlob = useCallback(
|
||||
(image: HTMLImageElement | null) => {
|
||||
if (!image) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
image.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
},
|
||||
[blob],
|
||||
);
|
||||
// eslint-disable-next-line @next/next/no-img-element -- blob: object URL, next/image cannot handle this
|
||||
return <img ref={showBlob} alt={alt} className={className} />;
|
||||
}
|
||||
|
||||
interface PhotoCaptureFieldProps {
|
||||
/** Position of the section in its form. */
|
||||
step?: number;
|
||||
/** The form's photo session (`photoSessionId` in its draft values). */
|
||||
sessionId: string;
|
||||
/** The inspection date (yyyy-mm-dd), part of each file name. */
|
||||
date: string;
|
||||
/** A photo was taken or removed: the form now has something to keep. */
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Foto aufnehmen" in an inspection form. Each photo is re-encoded without
|
||||
* EXIF/GPS and stored on the device at once, so it is kept with the form
|
||||
* draft and works without a network. Uploaded after the inspection is saved.
|
||||
* Shown only to users who may upload documents.
|
||||
*/
|
||||
export function PhotoCaptureField(props: PhotoCaptureFieldProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
if (!hasPermission("documents:upload")) {
|
||||
return null;
|
||||
}
|
||||
return <PhotoCapture {...props} />;
|
||||
}
|
||||
|
||||
function PhotoCapture({ step, sessionId, date, onChange }: PhotoCaptureFieldProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const ownerUserId = useCurrentUserId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [notice, setNotice] = useState<Notice>(NO_NOTICE);
|
||||
const queryKey = [...PHOTO_SESSION_QUERY_KEY, sessionId];
|
||||
|
||||
const { data: photos = [] } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => listSessionPhotos(sessionId),
|
||||
// Reads this device's storage, never the network.
|
||||
networkMode: "always",
|
||||
});
|
||||
const isFull = photos.length >= PHOTOS_PER_INSPECTION_MAX;
|
||||
|
||||
async function showStoredPhotos(): Promise<number> {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
return queryClient.getQueryData<unknown[]>(queryKey)?.length ?? 0;
|
||||
}
|
||||
|
||||
const add = useMutation({
|
||||
networkMode: "always",
|
||||
mutationFn: async (file: File) => {
|
||||
// Never store a photo without an owner: it would follow the next user.
|
||||
if (!ownerUserId) {
|
||||
throw new Error("No signed-in user");
|
||||
}
|
||||
const blob = await preparePhoto(file);
|
||||
return addDraftPhoto({ sessionId, ownerUserId, blob, date: date || todayLocalIsoDate() });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onChange?.();
|
||||
const count = await showStoredPhotos();
|
||||
setNotice({ text: `Foto ${count} gespeichert.`, isError: false });
|
||||
},
|
||||
onError: (error) => setNotice(captureError(error)),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
networkMode: "always",
|
||||
mutationFn: (id: string) => removePhoto(id),
|
||||
onSuccess: async () => {
|
||||
onChange?.();
|
||||
await showStoredPhotos();
|
||||
setNotice({ text: "Foto entfernt.", isError: false });
|
||||
},
|
||||
onError: () => setNotice({ text: "Foto konnte nicht entfernt werden.", isError: true }),
|
||||
});
|
||||
|
||||
function openCamera() {
|
||||
if (isFull) {
|
||||
setNotice({ text: LIMIT_MESSAGE, isError: true });
|
||||
return;
|
||||
}
|
||||
setNotice(NO_NOTICE);
|
||||
inputRef.current?.click();
|
||||
}
|
||||
|
||||
function handleFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
// Cleared, so the same picture can be chosen again after removing it.
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (isFull) {
|
||||
setNotice({ text: LIMIT_MESSAGE, isError: true });
|
||||
return;
|
||||
}
|
||||
add.mutate(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
step={step}
|
||||
title="Fotos"
|
||||
description={`Bis zu ${PHOTOS_PER_INSPECTION_MAX} Fotos. Standortdaten werden vor dem Speichern entfernt.`}
|
||||
>
|
||||
{photos.length > 0 ? (
|
||||
<ul className="grid grid-cols-3 gap-3" aria-label="Fotos dieser Durchsicht">
|
||||
{photos.map((photo, index) => {
|
||||
const name = `Foto ${index + 1}`;
|
||||
return (
|
||||
<li key={photo.id} className="flex flex-col gap-1">
|
||||
<PhotoThumbnail
|
||||
blob={photo.blob}
|
||||
alt={name}
|
||||
className="aspect-square w-full rounded-lg border border-border/60 object-cover"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
triggerLabel="Entfernen"
|
||||
triggerAriaLabel={`Entfernen: ${name}`}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title={`${name} entfernen?`}
|
||||
description="Das Foto wird von diesem Gerät gelöscht."
|
||||
confirmLabel="Entfernen"
|
||||
onConfirm={() => remove.mutate(photo.id)}
|
||||
isPending={remove.isPending}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="w-fit"
|
||||
disabled={add.isPending}
|
||||
onClick={openCamera}
|
||||
>
|
||||
<Camera aria-hidden />
|
||||
{add.isPending ? "Foto wird gespeichert…" : "Foto aufnehmen"}
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"text-sm empty:hidden",
|
||||
notice.isError ? "font-medium text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, CheckCircle2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface RoundNextPanelProps {
|
||||
/** Next colony to do, or null when the round is complete. */
|
||||
next: { id: string; code: string } | null;
|
||||
/** Colonies inspected in this round (for the closing message). */
|
||||
doneCount: number;
|
||||
/** Saved without a network: it syncs later. */
|
||||
savedOffline: boolean;
|
||||
onNext: () => void;
|
||||
onEnd: () => void;
|
||||
}
|
||||
|
||||
/** After a save in a round: one main step - on to the next colony. */
|
||||
export function RoundNextPanel({ next, doneCount, savedOffline, onNext, onEnd }: RoundNextPanelProps) {
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<p role="status" className="flex items-center gap-2 text-lg font-medium">
|
||||
<CheckCircle2 className="size-5 text-condition-good-foreground" aria-hidden />
|
||||
Gespeichert
|
||||
</p>
|
||||
{savedOffline ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kein Netzwerk – die Durchsicht wird synchronisiert, sobald du wieder Netz hast.
|
||||
</p>
|
||||
) : null}
|
||||
{next ? (
|
||||
<>
|
||||
<Button type="button" size="touch" className="h-12 text-base" onClick={onNext}>
|
||||
Weiter mit {next.code}
|
||||
<ArrowRight aria-hidden />
|
||||
</Button>
|
||||
<Button type="button" size="touch" variant="outline" onClick={onEnd}>
|
||||
Runde beenden
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Runde abgeschlossen – alle {doneCount} Völker</p>
|
||||
<Button type="button" size="touch" className="h-12 text-base" onClick={onEnd}>
|
||||
Zum Stand
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Visible note that a value is a suggestion and not something the user entered. */
|
||||
export function PrefillNote({ id, source }: { id: string; source: string | undefined }) {
|
||||
if (!source) return null;
|
||||
return (
|
||||
<p id={`${id}-prefill`} className="text-xs font-medium text-muted-foreground">
|
||||
aus letzter Behandlung mit {source}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The control's aria-describedby, extended by the prefill note when one is shown. */
|
||||
export function describedByWith(
|
||||
id: string,
|
||||
describedBy: string | undefined,
|
||||
source: string | undefined,
|
||||
): string | undefined {
|
||||
return [describedBy, source ? `${id}-prefill` : undefined].filter(Boolean).join(" ") || undefined;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { ChoiceGroup, ChoiceOption } from "@/components/form/choice-group";
|
||||
import {
|
||||
PERIOD_DAY_CHOICES,
|
||||
PeriodChoice,
|
||||
daysToMinutes,
|
||||
formatDayCount,
|
||||
parseDays,
|
||||
periodChoiceOf,
|
||||
} from "@/lib/varroa/period";
|
||||
|
||||
const PERIOD_OPTIONS: ChoiceOption<PeriodChoice>[] = [
|
||||
...PERIOD_DAY_CHOICES.map((days) => ({
|
||||
value: days,
|
||||
label: days === 1 ? "1 Tag" : `${days} Tage`,
|
||||
})),
|
||||
{ value: "other" as const, label: "anders" },
|
||||
];
|
||||
|
||||
interface VarroaPeriodFieldProps {
|
||||
/** Stored minutes as text; this stays the only source of truth. */
|
||||
minutes: string;
|
||||
onChange: (minutes: string) => void;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the board lay, picked in days. Still stores minutes: an old value
|
||||
* that is not a whole choice is shown as "anders" and kept exactly until the
|
||||
* user types a new number of days.
|
||||
*/
|
||||
export function VarroaPeriodField({ minutes, onChange, hint }: VarroaPeriodFieldProps) {
|
||||
const [otherChosen, setOtherChosen] = useState(false);
|
||||
const [daysText, setDaysText] = useState<string | null>(null);
|
||||
|
||||
const derived = periodChoiceOf(minutes);
|
||||
const selected: PeriodChoice | null = otherChosen ? "other" : derived;
|
||||
const storedMinutes = Number(minutes);
|
||||
const showsStored = daysText === null && derived === "other" && Number.isFinite(storedMinutes);
|
||||
const daysValue = daysText ?? (showsStored ? formatDayCount(storedMinutes) : "");
|
||||
|
||||
function choose(next: PeriodChoice | null) {
|
||||
if (next === "other") {
|
||||
setOtherChosen(true);
|
||||
setDaysText(Number.isFinite(storedMinutes) && storedMinutes > 0 ? formatDayCount(storedMinutes) : "");
|
||||
return;
|
||||
}
|
||||
setOtherChosen(false);
|
||||
setDaysText(null);
|
||||
onChange(next === null ? "" : String(daysToMinutes(next)));
|
||||
}
|
||||
|
||||
const daysInvalid = daysValue.trim() !== "" && parseDays(daysValue) === null;
|
||||
|
||||
function typeDays(text: string) {
|
||||
setDaysText(text);
|
||||
const days = parseDays(text);
|
||||
onChange(days === null ? "" : String(daysToMinutes(days)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="Zeitraum" group hint={hint}>
|
||||
{({ id, labelId, describedBy }) => (
|
||||
<ChoiceGroup
|
||||
id={id}
|
||||
labelledBy={labelId}
|
||||
describedBy={describedBy}
|
||||
options={PERIOD_OPTIONS}
|
||||
value={selected}
|
||||
onChange={choose}
|
||||
clearValue={null}
|
||||
className="grid-cols-3 sm:grid-cols-5"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{selected === "other" ? (
|
||||
<Field
|
||||
label="Tage"
|
||||
error={daysInvalid ? "Bitte eine Zahl größer als 0 eingeben, z. B. 1,5." : null}
|
||||
hint={
|
||||
showsStored
|
||||
? `anders: ${formatDayCount(storedMinutes)} Tage (${storedMinutes} Minuten)`
|
||||
: "z. B. 1,5 für anderthalb Tage"
|
||||
}
|
||||
>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-11"
|
||||
value={daysValue}
|
||||
onChange={(event) => typeDays(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AlertTriangle, CheckCircle2, OctagonAlert } from "lucide-react";
|
||||
import { RATING_TONE_CLASS } from "@/lib/forms/rating";
|
||||
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
|
||||
import { VarroaLight, varroaLight } from "@/lib/varroa/rate";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LIGHTS: Record<VarroaLight, { Icon: typeof CheckCircle2; tone: string }> = {
|
||||
green: { Icon: CheckCircle2, tone: RATING_TONE_CLASS[4] },
|
||||
yellow: { Icon: AlertTriangle, tone: RATING_TONE_CLASS[3] },
|
||||
red: { Icon: OctagonAlert, tone: RATING_TONE_CLASS[1] },
|
||||
};
|
||||
|
||||
function lightText(light: VarroaLight, threshold: number): string {
|
||||
if (light === "red") return "über der Schwelle, Behandlung prüfen";
|
||||
if (light === "yellow") return "nahe an der Schwelle";
|
||||
return `unter der Schwelle (${formatThreshold(threshold)}/Tag)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live result of the count. The state is always spelled out and has an
|
||||
* icon, so it never depends on the colour alone.
|
||||
*/
|
||||
export function VarroaRateLight({
|
||||
rate,
|
||||
threshold,
|
||||
}: {
|
||||
rate: number | null;
|
||||
threshold: number | null;
|
||||
}) {
|
||||
const light = varroaLight(rate, threshold);
|
||||
if (rate === null) {
|
||||
return <div role="status" aria-live="polite" />;
|
||||
}
|
||||
const base = `${formatMitesPerDay(rate)} Milben/Tag`;
|
||||
if (light === null || threshold === null) {
|
||||
return (
|
||||
<p role="status" aria-live="polite" className="text-sm font-medium">
|
||||
{base}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const { Icon, tone } = LIGHTS[light];
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn("flex min-h-11 items-center gap-2 rounded-lg border px-3 text-sm font-medium", tone)}
|
||||
>
|
||||
<Icon aria-hidden className="size-5 shrink-0" />
|
||||
<span>
|
||||
{base} – {lightText(light, threshold)}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasCombinedShopScopes } from "./scopes";
|
||||
|
||||
describe("hasCombinedShopScopes", () => {
|
||||
it("is true for a key that may read and book", () => {
|
||||
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for single-scope keys", () => {
|
||||
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read"] })).toBe(false);
|
||||
expect(hasCombinedShopScopes({ scopes: ["shop-sync:write"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ApiKey } from "./types";
|
||||
|
||||
export type ApiKeyScope = "shop-sync:read" | "shop-sync:write";
|
||||
|
||||
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
|
||||
"shop-sync:write": "Verkäufe und Rückbuchungen buchen (Wabenhain)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys issued before reading and booking were split may still carry both;
|
||||
* the server no longer creates such keys, the UI nudges towards two keys.
|
||||
*/
|
||||
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, "scopes">): boolean {
|
||||
return apiKey.scopes.includes("shop-sync:read") && apiKey.scopes.includes("shop-sync:write");
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
export type ApiKeyScope = "shop-sync:read";
|
||||
import type { ApiKeyScope } from "./scopes";
|
||||
|
||||
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
|
||||
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
|
||||
};
|
||||
export type { ApiKeyScope };
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import type { ColonyOverview } from "./field-types";
|
||||
|
||||
export function fetchColonyOverview(apiaryId: string): Promise<ColonyOverview> {
|
||||
return apiFetch<ColonyOverview>(`/apiaries/${encodeURIComponent(apiaryId)}/colony-overview`);
|
||||
}
|
||||
|
||||
/** Query key of the apiary page's colony overview. */
|
||||
export function colonyOverviewKey(apiaryId: string): readonly ["apiaries", string, "colony-overview"] {
|
||||
return ["apiaries", apiaryId, "colony-overview"] as const;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ApiaryStatus } from "./types";
|
||||
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
|
||||
import type { Inspection } from "@/lib/inspections/types";
|
||||
import type { Queen } from "@/lib/queens/types";
|
||||
import type { Task } from "@/lib/tasks/types";
|
||||
|
||||
// Mirrors ApiCore-Backend/src/apiaries/apiary-field.types.ts - keep both in step.
|
||||
|
||||
/** One living colony on the apiary page, reduced to what the traffic light needs. */
|
||||
export interface ColonyOverviewRow {
|
||||
id: string;
|
||||
code: string;
|
||||
status: ColonyStatus;
|
||||
strength: number | null;
|
||||
broodStatus: ColonyCondition | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
healthStatus: ColonyCondition | null;
|
||||
/** ISO timestamp; always null when the caller may not read inspections. */
|
||||
lastInspectionDate: string | null;
|
||||
/** Unread WARNUNG notifications visible to the caller. */
|
||||
openWarnings: number;
|
||||
/** Unread KRITISCH notifications visible to the caller. */
|
||||
openCritical: number;
|
||||
}
|
||||
|
||||
export interface ColonyOverview {
|
||||
apiaryId: string;
|
||||
inspectionsVisible: boolean;
|
||||
rows: ColonyOverviewRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the offline apiary mode needs. It deliberately carries no
|
||||
* coordinates, address or apiary notes (apiary locations are theft-sensitive)
|
||||
* and no inspection userId. Sections the caller has no read permission for
|
||||
* are null.
|
||||
*/
|
||||
export interface OfflinePackV1 {
|
||||
version: 1;
|
||||
generatedAt: string;
|
||||
apiary: {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
status: ApiaryStatus;
|
||||
};
|
||||
overview: ColonyOverview;
|
||||
colonies: Pick<
|
||||
Colony,
|
||||
| "id"
|
||||
| "code"
|
||||
| "apiaryId"
|
||||
| "status"
|
||||
| "colonyType"
|
||||
| "hiveType"
|
||||
| "strength"
|
||||
| "broodStatus"
|
||||
| "feedStatus"
|
||||
| "healthStatus"
|
||||
| "currentQueenId"
|
||||
| "notes"
|
||||
>[];
|
||||
queens:
|
||||
| Pick<
|
||||
Queen,
|
||||
"id" | "code" | "hatchYear" | "breed" | "markingColor" | "markingNumber" | "status"
|
||||
>[]
|
||||
| null;
|
||||
inspections: Omit<Inspection, "userId">[] | null;
|
||||
tasks:
|
||||
| Pick<
|
||||
Task,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "dueDate"
|
||||
| "priority"
|
||||
| "status"
|
||||
| "relatedType"
|
||||
| "relatedId"
|
||||
>[]
|
||||
| null;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface Apiary {
|
||||
address: string | null;
|
||||
status: ApiaryStatus;
|
||||
notes: string | null;
|
||||
/** Shown publicly on the origin page instead of the exact location. */
|
||||
publicLocationLabel: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deactivatedAt: string | null;
|
||||
@@ -24,6 +26,7 @@ export interface ApiaryInput {
|
||||
longitude?: number | null;
|
||||
address?: string | null;
|
||||
notes?: string | null;
|
||||
publicLocationLabel?: string | null;
|
||||
}
|
||||
|
||||
/** `null` clears an optional field, an omitted one stays unchanged. */
|
||||
@@ -35,4 +38,5 @@ export interface UpdateApiaryInput {
|
||||
longitude?: number | null;
|
||||
address?: string | null;
|
||||
notes?: string | null;
|
||||
publicLocationLabel?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { colonyOverviewKey, fetchColonyOverview } from "./field-api";
|
||||
|
||||
/** The apiary's living colonies with their traffic-light inputs. */
|
||||
export function useColonyOverview(apiaryId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: colonyOverviewKey(apiaryId),
|
||||
queryFn: () => fetchColonyOverview(apiaryId),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getConnectivity, reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import {
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DOWNLOAD_TIMEOUT_MS,
|
||||
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
} from "@/lib/http/fetch-with-timeout";
|
||||
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
|
||||
import { setAccessToken } from "./token-store";
|
||||
import { ApiError, apiFetch, apiFetchBlob, apiFetchFormData } from "./api-client";
|
||||
|
||||
vi.mock("../config/runtime-config", () => ({
|
||||
getApiUrl: vi.fn().mockResolvedValue("http://api.test"),
|
||||
}));
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("api-client", () => {
|
||||
beforeEach(() => {
|
||||
setAccessToken(null);
|
||||
reportRequestOutcome("reached-server");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ApiError carries the parsed JSON body", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(jsonResponse(400, { message: "Ungültig", invalidColonyIds: ["c1"] })),
|
||||
);
|
||||
const error = await apiFetch("/x").catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
const apiError = error as ApiError;
|
||||
expect(apiError.status).toBe(400);
|
||||
expect(apiError.message).toBe("Ungültig");
|
||||
expect((apiError.body as { invalidColonyIds: string[] }).invalidColonyIds).toEqual(["c1"]);
|
||||
});
|
||||
|
||||
it("reports network-error on a failed fetch and reached-server on any HTTP answer", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
await expect(apiFetch("/x")).rejects.toBeInstanceOf(TypeError);
|
||||
expect(getConnectivity()).toBe("offline");
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(500, { message: "boom" })));
|
||||
await expect(apiFetch("/x")).rejects.toBeInstanceOf(ApiError);
|
||||
expect(getConnectivity()).toBe("online");
|
||||
});
|
||||
|
||||
describe("timeouts", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
stubHangingFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("apiFetch: GET and keyed writes 4 s, unkeyed writes 15 s", async () => {
|
||||
await expectTimeoutAt(() => apiFetch("/x"), DEFAULT_TIMEOUT_MS);
|
||||
await expectTimeoutAt(
|
||||
() => apiFetch("/x", { method: "POST", headers: { "Idempotency-Key": "k" } }),
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
await expectTimeoutAt(() => apiFetch("/x", { method: "POST" }), NON_IDEMPOTENT_WRITE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("apiFetchFormData uses 30 s by default and honours timeoutMs", async () => {
|
||||
await expectTimeoutAt(() => apiFetchFormData("/x", new FormData()), UPLOAD_TIMEOUT_MS);
|
||||
await expectTimeoutAt(
|
||||
() => apiFetchFormData("/x", new FormData(), { timeoutMs: 5_000 }),
|
||||
5_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("apiFetchFormData passes options.headers through", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, {}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await apiFetchFormData("/x", new FormData(), { headers: { "Idempotency-Key": "k9" } });
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect(new Headers(init.headers).get("Idempotency-Key")).toBe("k9");
|
||||
});
|
||||
|
||||
it("apiFetchBlob uses 60 s", async () => {
|
||||
await expectTimeoutAt(() => apiFetchBlob("/x", "f.csv"), DOWNLOAD_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
it("apiFetchBlob builds an ApiError with the parsed body", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(403, { message: "Nein", why: 1 })));
|
||||
const error = (await apiFetchBlob("/x", "f.csv").catch((e: unknown) => e)) as ApiError;
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.body).toEqual({ message: "Nein", why: 1 });
|
||||
});
|
||||
});
|
||||
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user