feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes: - Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben - Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts) - Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe, Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand- Änderung, Konfliktblatt. Beute L5: - Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht, Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach Rückgängig, Folgevorschläge beim Speichern - Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“ T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite „Sprache & Datenschutz“; axe-core als exakte devDependency. Backend: NotificationsService liest Empfänger und schreibt in einer Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft. Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint, typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978). Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion. Refs #19 #20 #21 #22 #27 #32 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ab45d6e24e
commit
1a57de8573
123 files changed
+7639
-355
No files matched your search
@@ -0,0 +1,255 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { fetchHive, fetchHiveSystemsWithFallback, hiveKeys, postHiveChange } from "@/lib/hive/api";
|
||||
import { updateHiveSystem } from "@/lib/hive/catalog-api";
|
||||
import { colonyFixture, sampleState, setupView, systemDto } from "@/components/hive/test-fixtures";
|
||||
import HiveEditorPage from "./page";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useParams: () => ({ id: "colony-1" }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn(), fetchColonies: vi.fn().mockResolvedValue([]) }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHive: vi.fn(),
|
||||
fetchHiveSystemsWithFallback: vi.fn(),
|
||||
postHiveChange: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/hive/catalog-api", () => ({ updateHiveSystem: vi.fn() }));
|
||||
let permissions: string[] = [];
|
||||
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
|
||||
useCurrentUserId: () => null,
|
||||
}));
|
||||
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
|
||||
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/offline/connectivity")>()),
|
||||
useConnectivity: () => connectivity.value,
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<HiveEditorPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, client };
|
||||
}
|
||||
|
||||
/** Clicks „Hinzufügen …“ of the part type, then the dialog's „Hinzufügen“. */
|
||||
async function addPart(name: string) {
|
||||
await userEvent.click(await screen.findByRole("button", { name: `Hinzufügen … ${name}` }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Hinzufügen" }));
|
||||
return dialog;
|
||||
}
|
||||
|
||||
describe("HiveEditorPage", () => {
|
||||
beforeEach(() => {
|
||||
permissions = ["colonies:read", "colonies:update"];
|
||||
connectivity.value = "online";
|
||||
push.mockReset();
|
||||
vi.mocked(fetchColony).mockReset().mockResolvedValue(colonyFixture());
|
||||
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
|
||||
vi.mocked(fetchHiveSystemsWithFallback).mockReset().mockResolvedValue([systemDto()]);
|
||||
vi.mocked(postHiveChange).mockReset().mockResolvedValue(setupView());
|
||||
vi.mocked(updateHiveSystem).mockReset().mockResolvedValue(systemDto());
|
||||
});
|
||||
|
||||
it("shows the offline hint with a way back", async () => {
|
||||
connectivity.value = "offline";
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Umbauen geht nur mit Netz")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Zurück zum Volk/ })).toHaveAttribute("href", "/colonies/colony-1");
|
||||
});
|
||||
|
||||
it("adds a part, undoes it with Ctrl+Z and redoes it with Ctrl+Shift+Z", async () => {
|
||||
renderPage();
|
||||
const stack = await screen.findByRole("list", { name: "Aufbau" });
|
||||
expect(within(stack).getAllByRole("listitem")).toHaveLength(7); // 6 parts + the floor insert
|
||||
|
||||
await addPart("cover");
|
||||
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard("{Control>}z{/Control}");
|
||||
expect(within(stack).queryByRole("button", { name: "Entfernen: Abdeckfolie" })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard("{Control>}{Shift>}z{/Shift}{/Control}");
|
||||
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the floor rule inline when a second floor is added", async () => {
|
||||
renderPage();
|
||||
|
||||
const dialog = await addPart("floor");
|
||||
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Der Boden muss ganz unten liegen.");
|
||||
});
|
||||
|
||||
it("moves and removes parts from the stack list", async () => {
|
||||
renderPage();
|
||||
const stack = await screen.findByRole("list", { name: "Aufbau" });
|
||||
|
||||
await userEvent.click(within(stack).getByRole("button", { name: "Nach unten: Zarge 2" }));
|
||||
expect(within(stack).getAllByRole("button", { name: /^Nach (oben|unten): Zarge/ })).toHaveLength(4);
|
||||
await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
|
||||
expect(within(stack).queryByRole("button", { name: "Entfernen: Futterzarge 2" })).not.toBeInTheDocument();
|
||||
expect(within(stack).getByRole("button", { name: "Entfernen: Futterzarge" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves all collected ops as one EDITOR change and leaves", async () => {
|
||||
renderPage();
|
||||
await addPart("cover");
|
||||
await userEvent.click(within(await screen.findByRole("list", { name: "Aufbau" })).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(postHiveChange).toHaveBeenCalledTimes(1);
|
||||
const [colonyId, input] = vi.mocked(postHiveChange).mock.calls[0];
|
||||
expect(colonyId).toBe("colony-1");
|
||||
expect(input.source).toBe("EDITOR");
|
||||
expect(input.occurredOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(input.ops.map((op) => op.op)).toEqual(["addPart", "removePart"]);
|
||||
await vi.waitFor(() => expect(push).toHaveBeenCalledWith("/colonies/colony-1"));
|
||||
});
|
||||
|
||||
it("asks before „Verwerfen“ when ops exist and not when there are none", async () => {
|
||||
renderPage();
|
||||
await screen.findByRole("list", { name: "Aufbau" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
await addPart("cover");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Änderungen verwerfen?" });
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Verwerfen bestätigen" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("asks before the back link leaves with open ops", async () => {
|
||||
renderPage();
|
||||
await addPart("cover");
|
||||
|
||||
await userEvent.click(screen.getByRole("link", { name: /Zurück/ }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Umbau verlassen?" });
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Verlassen" }));
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
|
||||
});
|
||||
|
||||
it("offers „Als Vorlage speichern“ only with hive-systems:manage and sends the template", async () => {
|
||||
const { unmount } = renderPage();
|
||||
await screen.findByRole("list", { name: "Aufbau" });
|
||||
expect(screen.queryByRole("button", { name: "Als Vorlage speichern" })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
permissions = [...permissions, "hive-systems:manage"];
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Als Vorlage speichern" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Vorlage ersetzen" }));
|
||||
|
||||
await vi.waitFor(() => expect(updateHiveSystem).toHaveBeenCalledTimes(1));
|
||||
const [systemId, patch] = vi.mocked(updateHiveSystem).mock.calls[0];
|
||||
expect(systemId).toBe("sys");
|
||||
expect(JSON.stringify(patch.template)).not.toContain('"id"');
|
||||
});
|
||||
const conflictBody = {
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 0,
|
||||
reason: "MISSING_ID: x",
|
||||
current: { version: 9, state: sampleState() },
|
||||
};
|
||||
|
||||
it("shows the save error for a network failure but not for a conflict", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(500, "boom"));
|
||||
renderPage();
|
||||
await addPart("cover");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByText(/Speichern fehlgeschlagen/)).toBeInTheDocument();
|
||||
|
||||
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(409, "conflict", conflictBody));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Speichern fehlgeschlagen/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("discards the draft when the conflict is discarded", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(409, "conflict", conflictBody));
|
||||
renderPage();
|
||||
await addPart("cover");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" });
|
||||
|
||||
await userEvent.click(within(sheet).getByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled());
|
||||
expect(screen.queryByRole("button", { name: "Entfernen: Abdeckfolie" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps showing the draft's own base and hints when the server state changes underneath", async () => {
|
||||
const { client } = renderPage();
|
||||
await addPart("cover");
|
||||
expect(screen.queryByText(/inzwischen geändert/)).not.toBeInTheDocument();
|
||||
|
||||
const other = sampleState();
|
||||
other.stack = other.stack.filter((p) => p.id !== "b2");
|
||||
act(() => client.setQueryData(hiveKeys.detail("colony-1"), setupView(other, "colony-1", 4)));
|
||||
|
||||
expect(await screen.findByText(/Die Beute wurde inzwischen geändert\. Beim Speichern/)).toBeInTheDocument();
|
||||
const stack = screen.getByRole("list", { name: "Aufbau" });
|
||||
expect(within(stack).getByRole("button", { name: "Entfernen: Zarge 2" })).toBeInTheDocument();
|
||||
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a dialog refusal only in the dialog", async () => {
|
||||
renderPage();
|
||||
|
||||
const dialog = await addPart("floor");
|
||||
|
||||
expect(await within(dialog).findByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not bring a selection back when a removed part returns by undo", async () => {
|
||||
renderPage();
|
||||
const stack = await screen.findByRole("list", { name: "Aufbau" });
|
||||
await userEvent.click(within(stack).getByRole("button", { name: "Futterzarge 2" }));
|
||||
expect(within(stack).getByRole("button", { name: "Futterzarge 2" })).toHaveAttribute("aria-current", "true");
|
||||
|
||||
await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
|
||||
await userEvent.keyboard("{Control>}z{/Control}");
|
||||
|
||||
expect(within(stack).getByRole("button", { name: "Futterzarge 2" })).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("does not retry a missing hive", async () => {
|
||||
vi.mocked(fetchHive).mockReset().mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Noch keine Beute")).toBeInTheDocument();
|
||||
expect(fetchHive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("announces undo and redo", async () => {
|
||||
renderPage();
|
||||
await addPart("cover");
|
||||
|
||||
await userEvent.keyboard("{Control>}z{/Control}");
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/^Rückgängig: /);
|
||||
await userEvent.keyboard("{Control>}{Shift>}z{/Shift}{/Control}");
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/^Wiederholt: /);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { EditorNotice } from "@/components/hive/editor/editor-notice";
|
||||
import { HiveEditor } from "@/components/hive/editor/hive-editor";
|
||||
import { useHiveSystems, useSystemCatalog } from "@/components/hive/use-hive-systems";
|
||||
|
||||
/** Full-screen „Umbauen“ of a colony's hive (spec 9.5). Online only. */
|
||||
export default function HiveEditorPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { hasPermission } = useAuth();
|
||||
const connectivity = useConnectivity();
|
||||
const backHref = `/colonies/${encodeURIComponent(id)}`;
|
||||
const colony = useQuery({ queryKey: ["colonies", id], queryFn: () => fetchColony(id) });
|
||||
const hive = useQuery({ queryKey: hiveKeys.detail(id), queryFn: () => fetchHive(id),
|
||||
// A colony without a hive answers 404 for good; no point retrying.
|
||||
retry: (count, error) => hiveErrorCode(error) !== "HIVE_NOT_FOUND" && count < 3,
|
||||
});
|
||||
const systems = useHiveSystems();
|
||||
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
|
||||
|
||||
if (connectivity === "offline") {
|
||||
return (
|
||||
<EditorNotice title="Umbauen geht nur mit Netz" backHref={backHref}>
|
||||
<p className="text-sm">Kleine Änderungen in der Karte gehen auch ohne Netz.</p>
|
||||
</EditorNotice>
|
||||
);
|
||||
}
|
||||
if (colony.data && (!hasPermission("colonies:update") || !isLivingColony(colony.data))) {
|
||||
return (
|
||||
<EditorNotice title="Umbauen ist hier nicht möglich" backHref={backHref}>
|
||||
<p className="text-sm">Dafür fehlt die Berechtigung, oder das Volk ist nicht mehr im Bestand.</p>
|
||||
</EditorNotice>
|
||||
);
|
||||
}
|
||||
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
|
||||
return (
|
||||
<EditorNotice title="Noch keine Beute" backHref={backHref}>
|
||||
<p className="text-sm">Lege die Beute zuerst auf der Volk-Seite an.</p>
|
||||
</EditorNotice>
|
||||
);
|
||||
}
|
||||
if (colony.isError || hive.isError || systems.isError) {
|
||||
return (
|
||||
<EditorNotice title="Beute konnte nicht geladen werden." backHref={backHref}>
|
||||
<button
|
||||
type="button"
|
||||
className="h-11 rounded-xl border border-white/20 px-4 text-sm"
|
||||
onClick={() => {
|
||||
void colony.refetch();
|
||||
void hive.refetch();
|
||||
void systems.refetch();
|
||||
}}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</EditorNotice>
|
||||
);
|
||||
}
|
||||
if (!colony.data || !hive.data || !catalog) {
|
||||
return <div role="status" aria-label="Beute wird geladen" className="fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
|
||||
}
|
||||
return (
|
||||
<HiveEditor
|
||||
colonyId={id}
|
||||
setup={hive.data}
|
||||
catalog={catalog}
|
||||
canManageTemplates={hasPermission("hive-systems:manage")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { inlineCatalog } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
import { sampleState } from "@/components/hive/test-fixtures";
|
||||
import {
|
||||
broodHintFor,
|
||||
broodHintLabel,
|
||||
hiveEntryTexts,
|
||||
hiveErrorText,
|
||||
undoHiveEntry,
|
||||
} from "./hive-change";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const base = sampleState();
|
||||
|
||||
const FILL: HiveOp = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
|
||||
const FRAME: HiveOp = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
|
||||
|
||||
describe("hiveEntryTexts", () => {
|
||||
it("describes each op against the state the ops before it left", () => {
|
||||
const texts = hiveEntryTexts([FRAME, FILL], base, catalog);
|
||||
|
||||
expect(texts).toHaveLength(2);
|
||||
expect(texts[0]).toMatch(/Rähmchen 5/);
|
||||
expect(texts[1]).toMatch(/Flüssigfutter/);
|
||||
});
|
||||
|
||||
it("falls back to a plain sentence when the ops no longer apply", () => {
|
||||
const broken: HiveOp = { op: "removePart", id: "missing" };
|
||||
|
||||
expect(hiveEntryTexts([broken], base, catalog)).toEqual(["Änderung am Aufbau"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undoHiveEntry", () => {
|
||||
it("rebuilds the ops without the chosen entry", () => {
|
||||
const result = undoHiveEntry([FRAME, FILL], 0, base, catalog);
|
||||
|
||||
expect(result).toEqual({ ok: true, ops: [FILL] });
|
||||
});
|
||||
|
||||
it("refuses with a reason text when the rest does not apply any more", () => {
|
||||
const add: HiveOp = {
|
||||
op: "addPart",
|
||||
part: { id: "n1", partTypeId: "feeder", state: { syrupLiters: 0, fondantKg: 0 } },
|
||||
at: 5,
|
||||
};
|
||||
const fill: HiveOp = { op: "setState", id: "n1", key: "syrupLiters", value: 1 };
|
||||
|
||||
const result = undoHiveEntry([add, fill], 0, base, catalog);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toBe("Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.");
|
||||
});
|
||||
|
||||
it("ignores an index outside the list", () => {
|
||||
expect(undoHiveEntry([FILL], 3, base, catalog)).toEqual({ ok: true, ops: [FILL] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("broodHintFor", () => {
|
||||
it("counts the brood frames of the state the ops lead to", () => {
|
||||
expect(broodHintFor(base, [], catalog)).toBe(6);
|
||||
expect(broodHintFor(base, [FRAME], catalog)).toBe(7);
|
||||
});
|
||||
|
||||
it("is null while there is no setup", () => {
|
||||
expect(broodHintFor(null, [], catalog)).toBeNull();
|
||||
});
|
||||
|
||||
it("is null when the ops do not apply", () => {
|
||||
expect(broodHintFor(base, [{ op: "removePart", id: "missing" }], catalog)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("broodHintLabel", () => {
|
||||
it("uses singular and plural", () => {
|
||||
expect(broodHintLabel(1)).toBe("1 Brutwabe");
|
||||
expect(broodHintLabel(6)).toBe("6 Brutwaben");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hiveErrorText", () => {
|
||||
const conflict = new ApiError(409, "Conflict", {
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 0,
|
||||
reason: "MISSING_ID: no part x",
|
||||
current: { version: 4, state: base },
|
||||
});
|
||||
|
||||
it("explains a conflict with the reason text", () => {
|
||||
expect(hiveErrorText(conflict)).toBe(
|
||||
"Die Beute hat sich inzwischen geändert. Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr. Nimm die Änderungen an der Beute heraus und speichere erneut.",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains a missing right", () => {
|
||||
const forbidden = new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" });
|
||||
|
||||
expect(hiveErrorText(forbidden)).toBe(
|
||||
"Dir fehlt das Recht, die Beute zu ändern. Nimm die Änderungen an der Beute heraus und speichere erneut.",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves other errors to the form", () => {
|
||||
expect(hiveErrorText(new ApiError(500, "Boom"))).toBeNull();
|
||||
expect(hiveErrorText(null)).toBeNull();
|
||||
expect(hiveErrorText(new ApiError(403, "Forbidden", { code: "OTHER" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { hiveConflictOf, hiveErrorCode } from "@/lib/hive/api";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
|
||||
|
||||
const FALLBACK_ENTRY_TEXT = "Änderung am Aufbau";
|
||||
|
||||
/** One German line per op, each described against the state the ops before it leave. */
|
||||
export function hiveEntryTexts(ops: HiveOp[], base: HiveState, catalog: HiveCatalog): string[] {
|
||||
let state = base;
|
||||
return ops.map((op) => {
|
||||
const text = describeHiveOps([op], state, catalog);
|
||||
const applied = applyHiveOps(state, [op], catalog);
|
||||
if (!applied.ok) {
|
||||
// The ops no longer fit the loaded setup (it changed meanwhile): keep the list readable.
|
||||
state = base;
|
||||
return FALLBACK_ENTRY_TEXT;
|
||||
}
|
||||
state = applied.state;
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
export type UndoResult = { ok: true; ops: HiveOp[] } | { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* The ops without entry `index`. When the rest no longer applies to the setup
|
||||
* (a later op builds on the removed one) the entry stays and the reason is given.
|
||||
*/
|
||||
export function undoHiveEntry(
|
||||
ops: HiveOp[],
|
||||
index: number,
|
||||
base: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
): UndoResult {
|
||||
const rest = ops.filter((_, position) => position !== index);
|
||||
const applied = applyHiveOps(base, rest, catalog);
|
||||
return applied.ok ? { ok: true, ops: rest } : { ok: false, reason: hiveReasonText(applied.reason) };
|
||||
}
|
||||
|
||||
/** Brood frames of the build the ops lead to, or null without a setup or when the ops do not apply. */
|
||||
export function broodHintFor(base: HiveState | null, ops: HiveOp[], catalog: HiveCatalog | null): number | null {
|
||||
if (!base || !catalog) return null;
|
||||
const applied = applyHiveOps(base, ops, catalog);
|
||||
return applied.ok ? deriveHiveFacts(applied.state, catalog).broodFrames : null;
|
||||
}
|
||||
|
||||
export function broodHintLabel(count: number): string {
|
||||
return count === 1 ? "1 Brutwabe" : `${count} Brutwaben`;
|
||||
}
|
||||
|
||||
const TAKE_OUT_HINT = "Nimm die Änderungen an der Beute heraus und speichere erneut.";
|
||||
|
||||
/** Inline text for a refusal of the hive part (409 HIVE_CONFLICT, 403 HIVE_FORBIDDEN); null for any other error. */
|
||||
export function hiveErrorText(error: unknown): string | null {
|
||||
const conflict = hiveConflictOf(error);
|
||||
if (conflict) {
|
||||
return `Die Beute hat sich inzwischen geändert. ${hiveReasonText(conflict.reason)} ${TAKE_OUT_HINT}`;
|
||||
}
|
||||
if (hiveErrorCode(error) === "HIVE_FORBIDDEN") {
|
||||
return `Dir fehlt das Recht, die Beute zu ändern. ${TAKE_OUT_HINT}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { showHiveFollowUps } from "@/components/hive/follow-up-toast";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
|
||||
import { fetchColonyTreatments } from "@/lib/treatments/api";
|
||||
|
||||
async function hasOpenTreatment(colonyId: string): Promise<boolean> {
|
||||
try {
|
||||
const treatments = await fetchColonyTreatments(colonyId);
|
||||
return treatments.some((treatment) => treatment.endDate == null);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SavedHiveChange {
|
||||
colonyId: string;
|
||||
/** Calendar day of the inspection; becomes the date of the suggested forms. */
|
||||
occurredOn: string;
|
||||
ops: HiveOp[];
|
||||
/** The setup the ops were collected against. */
|
||||
base: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
navigate: (href: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec section 8: after the inspection is saved, the batch's feeding and
|
||||
* treatment suggestions appear as toasts. Suggestions only, nothing is booked.
|
||||
*/
|
||||
export async function suggestAfterInspection(saved: SavedHiveChange): Promise<void> {
|
||||
const { ops, base, catalog, colonyId, occurredOn, navigate } = saved;
|
||||
const applied = applyHiveOps(base, ops, catalog);
|
||||
// The server was authoritative and saved the inspection: if the ops do not apply to our
|
||||
// (possibly stale) copy of the setup, we just skip the suggestions instead of guessing.
|
||||
if (!applied.ok) return;
|
||||
const open = ops.some((op) => op.op === "removePart") ? await hasOpenTreatment(colonyId) : false;
|
||||
showHiveFollowUps(ops, base, applied.state, catalog, {
|
||||
colonyId,
|
||||
occurredOn,
|
||||
navigate,
|
||||
hasOpenTreatment: open,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HiveCard } from "@/components/hive/hive-card";
|
||||
import { useHiveSystems, useSystemCatalog } from "@/components/hive/use-hive-systems";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { fetchHive, hiveKeys } from "@/lib/hive/api";
|
||||
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
|
||||
import { hiveEntryTexts, undoHiveEntry } from "./hive-change";
|
||||
|
||||
/** The loaded setup and catalog of a colony's hive; same queries (and cache) as the card. */
|
||||
export function useInspectionHive(colonyId: string): { base: HiveState | null; catalog: HiveCatalog | null } {
|
||||
const enabled = colonyId !== "";
|
||||
const hive = useQuery({
|
||||
queryKey: hiveKeys.detail(colonyId),
|
||||
queryFn: () => fetchHive(colonyId),
|
||||
enabled,
|
||||
retry: false,
|
||||
});
|
||||
const systems = useHiveSystems();
|
||||
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
|
||||
return { base: hive.data?.state ?? null, catalog };
|
||||
}
|
||||
|
||||
/** Stable per entry: the op's content plus its n-th occurrence, so removing another entry keeps the key. */
|
||||
function opKeys(ops: HiveOp[]): string[] {
|
||||
const seen = new Map<string, number>();
|
||||
return ops.map((op) => {
|
||||
const json = JSON.stringify(op);
|
||||
const nth = seen.get(json) ?? 0;
|
||||
seen.set(json, nth + 1);
|
||||
return `${json}#${nth}`;
|
||||
});
|
||||
}
|
||||
|
||||
interface HiveOpsListProps {
|
||||
ops: HiveOp[];
|
||||
base: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
onChange: (ops: HiveOp[]) => void;
|
||||
/** Called with the undone entry's text, for the live region. */
|
||||
onUndone: (text: string) => void;
|
||||
/** The list became empty: focus moves to the section. */
|
||||
onEmptied: () => void;
|
||||
}
|
||||
|
||||
function HiveOpsList({ ops, base, catalog, onChange, onUndone, onEmptied }: HiveOpsListProps) {
|
||||
const buttons = useRef(new Map<string, HTMLButtonElement>());
|
||||
const keys = useMemo(() => opKeys(ops), [ops]);
|
||||
// Index to focus once the shortened list is committed (rows may remount when keys renumber).
|
||||
const focusIndex = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (focusIndex.current === null) return;
|
||||
const target = keys[Math.min(focusIndex.current, keys.length - 1)];
|
||||
focusIndex.current = null;
|
||||
if (target) buttons.current.get(target)?.focus();
|
||||
}, [keys]);
|
||||
const [refusal, setRefusal] = useState<{ ops: HiveOp[]; index: number; reason: string } | null>(null);
|
||||
const texts = useMemo(() => hiveEntryTexts(ops, base, catalog), [ops, base, catalog]);
|
||||
// A refusal belongs to the list it was given for; a changed list makes it obsolete.
|
||||
const blocked = refusal?.ops === ops ? refusal : null;
|
||||
|
||||
function undo(index: number) {
|
||||
const result = undoHiveEntry(ops, index, base, catalog);
|
||||
if (result.ok) {
|
||||
// The neighbouring entry gets focus after the commit (see the effect above).
|
||||
if (keys.length > 1) focusIndex.current = index;
|
||||
else onEmptied();
|
||||
onUndone(texts[index]);
|
||||
onChange(result.ops);
|
||||
} else setRefusal({ ops, index, reason: result.reason });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold">Änderungen an der Beute ({ops.length})</h3>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{texts.map((text, index) => (
|
||||
<li key={keys[index]} className="flex flex-col gap-1 rounded-xl bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm">{text}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
ref={(node) => {
|
||||
if (node) buttons.current.set(keys[index], node);
|
||||
else buttons.current.delete(keys[index]);
|
||||
}}
|
||||
className="min-h-11 shrink-0"
|
||||
aria-label={`Rückgängig: ${text}`}
|
||||
onClick={() => undo(index)}
|
||||
>
|
||||
Rückgängig
|
||||
</Button>
|
||||
</div>
|
||||
{blocked?.index === index ? (
|
||||
<p role="alert" className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
{blocked.reason} Mach zuerst die späteren Änderungen rückgängig.
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface InspectionHiveSectionProps {
|
||||
colony: Colony;
|
||||
/** colonies:update: edit the hive as a draft; without it the card is read-only. */
|
||||
canEdit: boolean;
|
||||
ops: HiveOp[];
|
||||
/** Changes whenever the card must be rebuilt from `ops` (after „Rückgängig“, new colony). */
|
||||
cardKey: string;
|
||||
onOpsChange: (ops: HiveOp[]) => void;
|
||||
/** Rebuilds the card with these ops (after „Rückgängig“). */
|
||||
onOpsReplace: (ops: HiveOp[]) => void;
|
||||
base: HiveState | null;
|
||||
catalog: HiveCatalog | null;
|
||||
/** Server refusal of the hive part (409 HIVE_CONFLICT, 403), shown right here. */
|
||||
errorText: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of the form's „Beute“ section: the card (draft mode only with
|
||||
* colonies:update), the list of collected changes and a server refusal.
|
||||
*/
|
||||
export function InspectionHiveSection({
|
||||
colony,
|
||||
canEdit,
|
||||
ops,
|
||||
cardKey,
|
||||
onOpsChange,
|
||||
onOpsReplace,
|
||||
base,
|
||||
catalog,
|
||||
errorText,
|
||||
}: InspectionHiveSectionProps) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
return (
|
||||
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
|
||||
<div
|
||||
ref={sectionRef}
|
||||
data-testid="hive-section"
|
||||
tabIndex={-1}
|
||||
className="flex flex-col gap-4 outline-none"
|
||||
onSubmit={(event) => event.stopPropagation()}
|
||||
>
|
||||
<p role="status" aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</p>
|
||||
<div className="flex h-[min(55svh,520px)] min-h-80 flex-col *:min-h-0 *:flex-1">
|
||||
<HiveCard
|
||||
key={cardKey}
|
||||
colonyId={colony.id}
|
||||
colony={colony}
|
||||
mode={canEdit ? "inspection" : "view"}
|
||||
onOpsChange={canEdit ? onOpsChange : undefined}
|
||||
initialOps={canEdit ? ops : undefined}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && ops.length > 0 && base && catalog ? (
|
||||
<HiveOpsList
|
||||
ops={ops}
|
||||
base={base}
|
||||
catalog={catalog}
|
||||
onChange={onOpsReplace}
|
||||
onUndone={(text) => setAnnouncement(`Rückgängig: ${text}`)}
|
||||
onEmptied={() => sectionRef.current?.focus()}
|
||||
/>
|
||||
) : null}
|
||||
{errorText ? (
|
||||
<p role="alert" className="flex items-start gap-2 text-sm font-medium text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
{errorText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
import {
|
||||
currentDraftValues,
|
||||
feedLegacyHint,
|
||||
@@ -209,6 +210,31 @@ describe("Volkskarte selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCreateInspectionInput with hive ops", () => {
|
||||
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 2 }];
|
||||
const values = { ...initialInspectionValues("c-1"), date: "2026-10-01" };
|
||||
|
||||
it("carries hiveChange only when there are ops", () => {
|
||||
expect(toCreateInspectionInput(values, true, ops).hiveChange).toEqual({ ops });
|
||||
expect(toCreateInspectionInput(values, true, [])).not.toHaveProperty("hiveChange");
|
||||
expect(toCreateInspectionInput(values, true)).not.toHaveProperty("hiveChange");
|
||||
});
|
||||
|
||||
it("never sends hiveChange without colonies:update", () => {
|
||||
expect(toCreateInspectionInput(values, false, ops)).not.toHaveProperty("hiveChange");
|
||||
});
|
||||
|
||||
it("keeps applyToColony next to hiveChange", () => {
|
||||
const input = toCreateInspectionInput(
|
||||
{ ...values, colonyStrength: "6", applyToColony: ["occupiedFrames"] },
|
||||
true,
|
||||
ops,
|
||||
);
|
||||
|
||||
expect(input).toMatchObject({ applyToColony: ["occupiedFrames"], hiveChange: { ops } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedStatusOptions", () => {
|
||||
const labels = (stored: Parameters<typeof feedStatusOptions>[0]) =>
|
||||
feedStatusOptions(stored).map((option) => option.label);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/lib/colonies/condition";
|
||||
import type { ColonyCondition } from "@/lib/colonies/types";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import {
|
||||
COLONY_TAKEOVER_FIELDS,
|
||||
@@ -238,20 +239,26 @@ export function syncTakeoverSelection(
|
||||
|
||||
/**
|
||||
* What a new inspection sends: the correction input plus, when the beekeeper
|
||||
* may update colonies, the checked Volkskarte fields that have a value.
|
||||
* Omitted when empty, so nothing is asked of the server that was not wanted.
|
||||
* may update colonies, the checked Volkskarte fields that have a value and the
|
||||
* hive ops collected in the „Beute“ section. Both are omitted when empty, so
|
||||
* nothing is asked of the server that was not wanted.
|
||||
*/
|
||||
export function toCreateInspectionInput(
|
||||
values: InspectionFormValues,
|
||||
canUpdateColony: boolean,
|
||||
hiveOps: HiveOp[] = [],
|
||||
): CreateInspectionInput {
|
||||
const input = toInspectionInput(values);
|
||||
const input: CreateInspectionInput = toInspectionInput(values);
|
||||
if (!canUpdateColony) {
|
||||
return input;
|
||||
}
|
||||
const filled = new Set(takeoverFieldsFilled(values));
|
||||
const applyToColony = values.applyToColony.filter((field) => filled.has(field));
|
||||
return applyToColony.length > 0 ? { ...input, applyToColony } : input;
|
||||
return {
|
||||
...input,
|
||||
...(applyToColony.length > 0 ? { applyToColony } : {}),
|
||||
...(hiveOps.length > 0 ? { hiveChange: { ops: hiveOps } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { createPortal } from "react-dom";
|
||||
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 NewInspectionPage from "./page";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { createInspection, fetchColonyInspections } from "@/lib/inspections/api";
|
||||
import { fetchHive, fetchHiveSystems } from "@/lib/hive/api";
|
||||
import { fetchApiaryWeather } from "@/lib/weather/api";
|
||||
import { showHiveFollowUps } from "@/components/hive/follow-up-toast";
|
||||
import { sampleState, setupView, systemDto } from "@/components/hive/test-fixtures";
|
||||
import { queueInspectionWithPhotos } from "@/lib/photos/store";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
|
||||
// Section „Beute“ of the inspection form (Task 19). The card itself is stubbed:
|
||||
// its buttons report ops the way the real card does in draft mode.
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
|
||||
}));
|
||||
let permissions = new Set<string>();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useCurrentUserId: () => "user-1",
|
||||
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: 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/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
PHOTOS_PER_INSPECTION_MAX: 5,
|
||||
listSessionPhotos: vi.fn().mockResolvedValue([]),
|
||||
addDraftPhoto: vi.fn(),
|
||||
removePhoto: vi.fn(),
|
||||
handOverSessionPhotos: vi.fn().mockResolvedValue(0),
|
||||
queueInspectionWithPhotos: vi.fn().mockResolvedValue(0),
|
||||
moveDraftPhotos: vi.fn(),
|
||||
discardSessionPhotos: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/components/hive/follow-up-toast", () => ({ showHiveFollowUps: vi.fn() }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHive: vi.fn(),
|
||||
fetchHiveSystems: vi.fn(),
|
||||
}));
|
||||
|
||||
const card = vi.hoisted(() => ({ renders: [] as { mode?: string; initialOps?: unknown[] }[] }));
|
||||
vi.mock("@/components/hive/hive-card", () => ({
|
||||
HiveCard: (props: { mode?: string; initialOps?: unknown[]; onOpsChange?: (ops: unknown[]) => void }) => {
|
||||
card.renders.push({ mode: props.mode, initialOps: props.initialOps });
|
||||
const fill = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
|
||||
const frame = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
|
||||
const add = {
|
||||
op: "addPart",
|
||||
part: { id: "n1", partTypeId: "feeder", state: { syrupLiters: 0, fondantKg: 0 } },
|
||||
at: 5,
|
||||
};
|
||||
const fillNew = { op: "setState", id: "n1", key: "syrupLiters", value: 1 };
|
||||
return (
|
||||
<div data-testid="hive-card" data-mode={props.mode}>
|
||||
<button type="button" onClick={() => props.onOpsChange?.([fill])}>
|
||||
Test Futter
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onOpsChange?.([frame, fill])}>
|
||||
Test Rähmchen und Futter
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onOpsChange?.([add, fillNew])}>
|
||||
Test neue Zarge
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onOpsChange?.([fill, fill])}>
|
||||
Test zwei gleiche
|
||||
</button>
|
||||
{createPortal(
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<button type="submit">Sheet speichern</button>
|
||||
</form>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NewInspectionPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
|
||||
}
|
||||
|
||||
function sentInput(): Record<string, unknown> {
|
||||
return vi.mocked(createInspection).mock.calls[0][0] as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const FILL = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
|
||||
const FRAME = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
|
||||
|
||||
describe("NewInspectionPage: section Beute", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
push.mockReset();
|
||||
card.renders.length = 0;
|
||||
permissions = new Set(["colonies:update"]);
|
||||
vi.mocked(fetchColonies).mockResolvedValue([
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
|
||||
] as never);
|
||||
vi.mocked(fetchColonyInspections).mockResolvedValue([]);
|
||||
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
|
||||
vi.mocked(fetchHive).mockResolvedValue(setupView(sampleState()));
|
||||
vi.mocked(fetchHiveSystems).mockResolvedValue([systemDto()]);
|
||||
vi.mocked(createInspection)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
|
||||
vi.mocked(showHiveFollowUps).mockReset();
|
||||
vi.mocked(toast.success).mockReset();
|
||||
vi.mocked(queueInspectionWithPhotos).mockReset().mockResolvedValue(0);
|
||||
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
|
||||
});
|
||||
|
||||
it("shows the card in draft mode with colonies:update", async () => {
|
||||
renderPage();
|
||||
|
||||
const section = await screen.findByTestId("hive-card");
|
||||
expect(section).toHaveAttribute("data-mode", "inspection");
|
||||
expect(screen.getByRole("group", { name: /Beute/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the model read-only without colonies:update", async () => {
|
||||
permissions = new Set();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByTestId("hive-card")).toHaveAttribute("data-mode", "view");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Test Futter" }));
|
||||
expect(screen.queryByText(/Änderungen an der Beute/)).not.toBeInTheDocument();
|
||||
await save();
|
||||
expect(sentInput()).not.toHaveProperty("hiveChange");
|
||||
});
|
||||
|
||||
it("lists the changes and sends them with the inspection", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (2)" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^Rückgängig/ })).toHaveLength(2);
|
||||
await save();
|
||||
|
||||
expect(sentInput()).toMatchObject({ hiveChange: { ops: [FRAME, FILL] } });
|
||||
});
|
||||
|
||||
it("sends no hiveChange while there are no ops", async () => {
|
||||
renderPage();
|
||||
await screen.findByTestId("hive-card");
|
||||
await save();
|
||||
|
||||
expect(sentInput()).not.toHaveProperty("hiveChange");
|
||||
});
|
||||
|
||||
it("Rückgängig removes one entry and rebuilds the card from the rest", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
|
||||
|
||||
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (1)" })).toBeInTheDocument();
|
||||
expect(card.renders.at(-1)?.initialOps).toEqual([FILL]);
|
||||
await save();
|
||||
expect(sentInput()).toMatchObject({ hiveChange: { ops: [FILL] } });
|
||||
});
|
||||
|
||||
it("remounts the card seeded with the remaining ops after Rückgängig", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
|
||||
const rendersBefore = card.renders.length;
|
||||
|
||||
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[1]);
|
||||
|
||||
expect(card.renders.length).toBeGreaterThan(rendersBefore);
|
||||
expect(card.renders.at(-1)?.initialOps).toEqual([FRAME]);
|
||||
expect(screen.getByText(/Laut Beute: 7 Brutwaben/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not save the inspection when a form inside the card submits", async () => {
|
||||
renderPage();
|
||||
await screen.findByTestId("hive-card");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Sheet speichern" }));
|
||||
|
||||
expect(createInspection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves focus to the neighbouring entry and announces the undo", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
|
||||
|
||||
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
|
||||
expect(screen.getAllByRole("status").some((el) => /^Rückgängig: /.test(el.textContent ?? ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("moves focus to the section when the list becomes empty", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
|
||||
|
||||
expect(screen.getByTestId("hive-section")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps focus on a row when identical ops renumber their keys", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test zwei gleiche" }));
|
||||
|
||||
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("hides a refusal once the ops changed", async () => {
|
||||
vi.mocked(createInspection).mockRejectedValue(
|
||||
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),
|
||||
);
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
|
||||
await save();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Dir fehlt das Recht");
|
||||
|
||||
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the hint for 0 brood frames while the field is empty", async () => {
|
||||
vi.mocked(fetchHive).mockResolvedValue(
|
||||
setupView({ schemaVersion: 1, stack: [{ id: "r", partTypeId: "roof", state: {} }] } as never),
|
||||
);
|
||||
renderPage();
|
||||
await screen.findByTestId("hive-card");
|
||||
await waitFor(() => expect(fetchHive).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByText(/Laut Beute/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops hiveChange again when the last entry is undone", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
|
||||
|
||||
expect(screen.queryByText(/Änderungen an der Beute/)).not.toBeInTheDocument();
|
||||
await save();
|
||||
expect(sentInput()).not.toHaveProperty("hiveChange");
|
||||
});
|
||||
|
||||
it("keeps an entry and says why when the rest would no longer apply", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test neue Zarge" }));
|
||||
|
||||
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (2)" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr\./),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the brood frames of the hive but never fills them by itself", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/Laut Beute: 6 Brutwaben/)).toBeInTheDocument();
|
||||
await save();
|
||||
expect(sentInput()).toMatchObject({ broodFrames: null });
|
||||
});
|
||||
|
||||
it("takes the hive's brood frames over only after Übernehmen", async () => {
|
||||
renderPage();
|
||||
const hint = (await screen.findByText(/Laut Beute: 6 Brutwaben/)).closest("p") as HTMLElement;
|
||||
|
||||
await userEvent.click(within(hint).getByRole("button", { name: /übernehmen/ }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/Laut Beute/)).not.toBeInTheDocument());
|
||||
await save();
|
||||
expect(sentInput()).toMatchObject({ broodFrames: 6 });
|
||||
});
|
||||
|
||||
it("follows the ops: a brood frame in the draft raises the hint", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
|
||||
|
||||
expect(screen.getByText(/Laut Beute: 7 Brutwaben/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suggests feeding and treatment for the saved batch", async () => {
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
|
||||
await save();
|
||||
|
||||
await waitFor(() => expect(showHiveFollowUps).toHaveBeenCalledTimes(1));
|
||||
const [ops, before, after, , context] = vi.mocked(showHiveFollowUps).mock.calls[0];
|
||||
expect(ops).toEqual([FILL]);
|
||||
expect(before).toEqual(sampleState());
|
||||
expect(after).not.toEqual(before);
|
||||
expect(context).toMatchObject({ colonyId: "colony-1", hasOpenTreatment: false });
|
||||
});
|
||||
|
||||
it("suggests nothing when no hive change was sent", async () => {
|
||||
renderPage();
|
||||
await screen.findByTestId("hive-card");
|
||||
await save();
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(showHiveFollowUps).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes hiveChange untouched into the offline queue", async () => {
|
||||
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
|
||||
await save();
|
||||
|
||||
await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled());
|
||||
expect(vi.mocked(queueInspectionWithPhotos).mock.calls[0][1]).toMatchObject({
|
||||
hiveChange: { ops: [FILL] },
|
||||
});
|
||||
expect(showHiveFollowUps).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains a 409 HIVE_CONFLICT inline and keeps the ops", async () => {
|
||||
vi.mocked(createInspection).mockRejectedValue(
|
||||
new ApiError(409, "Conflict", {
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 0,
|
||||
reason: "MISSING_ID: no part",
|
||||
current: { version: 5, state: sampleState() },
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
|
||||
await save();
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Die Beute hat sich inzwischen geändert.");
|
||||
expect(alert).toHaveTextContent("Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.");
|
||||
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (1)" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("explains a 403 HIVE_FORBIDDEN inline", async () => {
|
||||
vi.mocked(createInspection).mockRejectedValue(
|
||||
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),
|
||||
);
|
||||
renderPage();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
|
||||
await save();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Dir fehlt das Recht, die Beute zu ändern.");
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,13 @@ vi.mock("@/lib/inspections/api", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
|
||||
// The Beute card and its data have their own specs (page-hive.spec.tsx); here the section stays inert.
|
||||
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHive: vi.fn().mockRejectedValue(new Error("no hive")),
|
||||
fetchHiveSystems: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
PHOTOS_PER_INSPECTION_MAX: 5,
|
||||
@@ -1110,4 +1117,76 @@ describe("NewInspectionPage", () => {
|
||||
expect(discardSessionPhotos).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("dictation", () => {
|
||||
class FakeRecognition {
|
||||
static instances: FakeRecognition[] = [];
|
||||
static available = vi.fn(async () => "available");
|
||||
lang = "";
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
maxAlternatives = 1;
|
||||
handlers: Record<string, (event: unknown) => void> = {};
|
||||
start = vi.fn();
|
||||
stop = vi.fn();
|
||||
abort = vi.fn();
|
||||
constructor() {
|
||||
FakeRecognition.instances.push(this);
|
||||
}
|
||||
set onresult(fn: (event: unknown) => void) {
|
||||
this.handlers.result = fn;
|
||||
}
|
||||
set onend(fn: (event: unknown) => void) {
|
||||
this.handlers.end = fn;
|
||||
}
|
||||
set onerror(fn: (event: unknown) => void) {
|
||||
this.handlers.error = fn;
|
||||
}
|
||||
set onaudiostart(fn: (event: unknown) => void) {
|
||||
this.handlers.audiostart = fn;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeRecognition.instances = [];
|
||||
(window as unknown as Record<string, unknown>).SpeechRecognition = FakeRecognition;
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).SpeechRecognition;
|
||||
});
|
||||
|
||||
async function openMoreFields() {
|
||||
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
|
||||
}
|
||||
|
||||
it("puts a dictation button next to notes, health, varroa and measures", async () => {
|
||||
renderPage();
|
||||
await openMoreFields();
|
||||
|
||||
for (const field of ["Notizen", "Gesundheitsauffälligkeiten", "Varroa-Befund", "Maßnahmen"]) {
|
||||
expect(
|
||||
await screen.findByRole("button", { name: `Diktieren: ${field}` }),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("appends the dictated text to a typed note and saves it", async () => {
|
||||
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
|
||||
renderPage();
|
||||
await openMoreFields();
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /^Notizen/ }), "Alt");
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Diktieren: Notizen" }));
|
||||
const engine = FakeRecognition.instances[0];
|
||||
act(() =>
|
||||
engine.handlers.result({
|
||||
resultIndex: 0,
|
||||
results: [Object.assign([{ transcript: "neu" }], { isFinal: true })],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: /^Notizen/ })).toHaveValue("Alt neu");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
|
||||
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ notes: "Alt neu" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from "@/lib/inspections/takeover";
|
||||
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
|
||||
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
|
||||
import { hiveKeys } from "@/lib/hive/api";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||
import { photosNotTakenOver } from "@/lib/offline/queue-texts";
|
||||
@@ -40,6 +42,8 @@ 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 { DictationRow } from "@/components/voice/dictation-button";
|
||||
import { appendDictation } from "@/lib/voice/append-text";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { RoundNextPanel } from "@/components/rounds/round-next-panel";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
@@ -66,6 +70,12 @@ import {
|
||||
syncTakeoverSelection,
|
||||
toCreateInspectionInput,
|
||||
} from "./inspection-form-values";
|
||||
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
|
||||
import { suggestAfterInspection } from "./hive-save-follow-ups";
|
||||
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
|
||||
|
||||
/** Free-text fields that take dictation. */
|
||||
type DictatedField = "notes" | "healthIssues" | "varroaIssues" | "measures";
|
||||
|
||||
const UNDO_TOAST_MS = 8000;
|
||||
const WEATHER_UPDATED = "Wetter aktualisiert";
|
||||
@@ -218,6 +228,13 @@ function InspectionCaptureForm({
|
||||
const colonyCard = colonies?.find((colony) => colony.id === values.colonyId);
|
||||
const stand = useStandRound(roundApiaryId, values.colonyId);
|
||||
const [roundSaved, setRoundSaved] = useState<RoundSaved | null>(null);
|
||||
// Hive ops collected in the „Beute“ section; sent with the inspection, never drafted.
|
||||
const [hiveOps, setHiveOps] = useState<HiveOp[]>([]);
|
||||
const [hiveCardGeneration, setHiveCardGeneration] = useState(0);
|
||||
// The ops of the last submit: a refusal only speaks for exactly those.
|
||||
const [submittedHiveOps, setSubmittedHiveOps] = useState("");
|
||||
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
|
||||
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
|
||||
|
||||
/**
|
||||
* Every change of the answers also keeps the Volkskarte selection in step.
|
||||
@@ -255,10 +272,33 @@ function InspectionCaptureForm({
|
||||
{ touched: TOUCHED_TAKEOVER_FIELD[key] },
|
||||
);
|
||||
setDirty(true);
|
||||
if (key === "colonyId") setColonyError(null);
|
||||
if (key === "colonyId") {
|
||||
// The ops belong to the other colony's hive.
|
||||
replaceHiveOps([]);
|
||||
setColonyError(null);
|
||||
}
|
||||
if (key === "date") setDateError(null);
|
||||
}
|
||||
|
||||
/** Ops reported by the card as the beekeeper edits. */
|
||||
function collectHiveOps(ops: HiveOp[]) {
|
||||
setHiveOps(ops);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
/** Ops changed from outside the card („Rückgängig“, new colony): the card is rebuilt from them. */
|
||||
function replaceHiveOps(ops: HiveOp[]) {
|
||||
setHiveOps(ops);
|
||||
setHiveCardGeneration((generation) => generation + 1);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
/** Dictation is added to the end of a free-text field and marks the form as changed. */
|
||||
function appendDictated(key: DictatedField, text: string) {
|
||||
update((current) => ({ ...current, [key]: appendDictation(current[key], text) }));
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
/** How many photos the form's photo section shows (none while it is hidden). */
|
||||
function shownPhotoCount(photoSessionId: string): number {
|
||||
return (
|
||||
@@ -336,6 +376,19 @@ function InspectionCaptureForm({
|
||||
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
|
||||
});
|
||||
toast.success("Durchsicht gespeichert");
|
||||
if (input.hiveChange) {
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(input.colonyId) });
|
||||
if (hiveBase && hiveCatalog) {
|
||||
void suggestAfterInspection({
|
||||
colonyId: input.colonyId,
|
||||
occurredOn: toCalendarDay(input.date),
|
||||
ops: input.hiveChange.ops,
|
||||
base: hiveBase,
|
||||
catalog: hiveCatalog,
|
||||
navigate: (href) => router.push(href),
|
||||
});
|
||||
}
|
||||
}
|
||||
const takeover = outcome.result.colonyTakeover;
|
||||
if (takeover?.applied.length) {
|
||||
// The colony card changed: its page and lists must not show the old values.
|
||||
@@ -367,7 +420,8 @@ function InspectionCaptureForm({
|
||||
return;
|
||||
}
|
||||
submittedPhotoSession.current = values.photoSessionId;
|
||||
capture.submit(toCreateInspectionInput(values, canUpdateColony));
|
||||
setSubmittedHiveOps(JSON.stringify(hiveOps));
|
||||
capture.submit(toCreateInspectionInput(values, canUpdateColony, hiveOps));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -496,6 +550,10 @@ function InspectionCaptureForm({
|
||||
|
||||
// Same rule as the backend automation: only an explicit "Nein" counts.
|
||||
const noQueenSigns = values.queenSeen === false && values.eggsPresent === false;
|
||||
const hiveError =
|
||||
hiveOps.length > 0 && JSON.stringify(hiveOps) === submittedHiveOps
|
||||
? hiveErrorText(capture.error)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -609,12 +667,46 @@ function InspectionCaptureForm({
|
||||
describedBy={describedBy}
|
||||
/>
|
||||
{hintFor("broodFrames")}
|
||||
{broodFromHive !== null &&
|
||||
values.broodFrames !== String(broodFromHive) &&
|
||||
!(broodFromHive === 0 && values.broodFrames === "") ? (
|
||||
<PreviousValueHint
|
||||
prefix="Laut Beute"
|
||||
fieldLabel="Brutwaben"
|
||||
valueLabel={broodHintLabel(broodFromHive)}
|
||||
onTakeOver={() => set("broodFrames", String(broodFromHive))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={3} title="Stärke & Verhalten">
|
||||
{colonyCard ? (
|
||||
<FormSection
|
||||
step={3}
|
||||
title="Beute"
|
||||
description={
|
||||
canUpdateColony
|
||||
? "Änderungen am Aufbau werden mit der Durchsicht gespeichert."
|
||||
: "Nur ansehen – Änderungen am Aufbau brauchen das Recht, Völker zu bearbeiten."
|
||||
}
|
||||
>
|
||||
<InspectionHiveSection
|
||||
colony={colonyCard}
|
||||
canEdit={canUpdateColony}
|
||||
ops={hiveOps}
|
||||
cardKey={`${colonyCard.id}:${hiveCardGeneration}`}
|
||||
onOpsChange={collectHiveOps}
|
||||
onOpsReplace={replaceHiveOps}
|
||||
base={hiveBase}
|
||||
catalog={hiveCatalog}
|
||||
errorText={hiveError}
|
||||
/>
|
||||
</FormSection>
|
||||
) : null}
|
||||
|
||||
<FormSection step={4} title="Stärke & Verhalten">
|
||||
<Field label="Volksstärke" hint="Anzahl besetzter Waben">
|
||||
{({ id, describedBy }) => (
|
||||
<>
|
||||
@@ -680,7 +772,7 @@ function InspectionCaptureForm({
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection step={4} title="Futter">
|
||||
<FormSection step={5} title="Futter">
|
||||
<Field label="Futterstatus" group>
|
||||
{({ id, labelId }) => (
|
||||
<>
|
||||
@@ -705,7 +797,7 @@ function InspectionCaptureForm({
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={5}
|
||||
step={6}
|
||||
title="Wetter"
|
||||
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
|
||||
>
|
||||
@@ -743,7 +835,7 @@ function InspectionCaptureForm({
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
step={6}
|
||||
step={7}
|
||||
title="Weitere Angaben"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
@@ -766,50 +858,70 @@ function InspectionCaptureForm({
|
||||
</Field>
|
||||
<Field label="Varroa-Befund">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.varroaIssues}
|
||||
onChange={(event) =>
|
||||
set("varroaIssues", event.target.value)
|
||||
}
|
||||
/>
|
||||
<DictationRow
|
||||
fieldLabel="Varroa-Befund"
|
||||
onAppend={(text) => appendDictated("varroaIssues", text)}
|
||||
>
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.varroaIssues}
|
||||
onChange={(event) =>
|
||||
set("varroaIssues", event.target.value)
|
||||
}
|
||||
/>
|
||||
</DictationRow>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Gesundheitsauffälligkeiten">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.healthIssues}
|
||||
onChange={(event) =>
|
||||
set("healthIssues", event.target.value)
|
||||
}
|
||||
/>
|
||||
<DictationRow
|
||||
fieldLabel="Gesundheitsauffälligkeiten"
|
||||
onAppend={(text) => appendDictated("healthIssues", text)}
|
||||
>
|
||||
<Input
|
||||
id={id}
|
||||
className="h-11"
|
||||
value={values.healthIssues}
|
||||
onChange={(event) =>
|
||||
set("healthIssues", event.target.value)
|
||||
}
|
||||
/>
|
||||
</DictationRow>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Maßnahmen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.measures}
|
||||
onChange={(event) => set("measures", event.target.value)}
|
||||
/>
|
||||
<DictationRow
|
||||
fieldLabel="Maßnahmen"
|
||||
onAppend={(text) => appendDictated("measures", text)}
|
||||
>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.measures}
|
||||
onChange={(event) => set("measures", event.target.value)}
|
||||
/>
|
||||
</DictationRow>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Notizen">
|
||||
{({ id }) => (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.notes}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
<DictationRow
|
||||
fieldLabel="Notizen"
|
||||
onAppend={(text) => appendDictated("notes", text)}
|
||||
>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values.notes}
|
||||
onChange={(event) => set("notes", event.target.value)}
|
||||
/>
|
||||
</DictationRow>
|
||||
)}
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<PhotoCaptureField
|
||||
step={7}
|
||||
step={8}
|
||||
sessionId={values.photoSessionId}
|
||||
date={values.date}
|
||||
onChange={() => setDirty(true)}
|
||||
@@ -833,7 +945,7 @@ function InspectionCaptureForm({
|
||||
|
||||
<StickySubmit
|
||||
note={
|
||||
capture.submitError ? (
|
||||
capture.submitError && hiveErrorText(capture.error) === null ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm font-medium text-destructive"
|
||||
|
||||
@@ -206,6 +206,21 @@ describe("AppLayout", () => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("links „Sprache & Datenschutz“ in the account menu for every user", async () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Konto-Menü" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: "Sprache & Datenschutz" })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/voice",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives the band the page content wrapper as a stable focus target", () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
|
||||
@@ -116,6 +116,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
<DropdownMenuItem render={<Link href="/profile" />}>
|
||||
Profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem render={<Link href="/settings/voice" />}>
|
||||
Sprache & Datenschutz
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
storedPack,
|
||||
} from "@/lib/apiaries/offline-pack.fixtures";
|
||||
import { stepFixture } from "@/lib/autopilot/step-fixtures";
|
||||
import { setupView, systemDto } from "@/components/hive/test-fixtures";
|
||||
import OfflineColonyPage from "./page";
|
||||
|
||||
let searchString = "id=colony-1";
|
||||
@@ -262,4 +263,39 @@ describe("OfflineColonyPage", () => {
|
||||
expect(await screen.findByText("Geladen vor 5 Minuten")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/bei Netz neu laden/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("hive", () => {
|
||||
it("shows the hive read-only as SVG and outline from the pack", async () => {
|
||||
await putPacks(
|
||||
storedPack({
|
||||
...fullPack(),
|
||||
hives: { "colony-1": setupView(undefined, "colony-1") },
|
||||
hiveCatalog: [systemDto()],
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const section = (await screen.findByRole("heading", { name: "Beute" })).closest(
|
||||
"section, [role=region]",
|
||||
) as HTMLElement;
|
||||
expect(await within(section).findByRole("img")).toBeInTheDocument();
|
||||
expect(within(section).getByRole("tree")).toBeInTheDocument();
|
||||
expect(within(section).queryByRole("button", { name: /Übernehmen|Bearbeiten|Umbauen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says there is no hive yet for a colony the pack knows has none", async () => {
|
||||
await putPacks(storedPack({ ...fullPack(), hives: {}, hiveCatalog: [systemDto()] }));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no hive section for a pack from before hives", async () => {
|
||||
await putPacks(storedPack(fullPack()));
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("heading", { name: "Volk" });
|
||||
expect(screen.queryByRole("heading", { name: "Beute" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import type { StoredPack } from "@/lib/offline/db";
|
||||
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
|
||||
import { NextStepsView } from "@/components/autopilot/next-steps-card";
|
||||
import { OfflineHive } from "@/components/hive/offline-hive-section";
|
||||
import { StatusLightBadge } from "@/components/colonies/status-light-badge";
|
||||
import { DetailHeader, DetailStatus } from "@/components/detail-header";
|
||||
import { PackAgeNote } from "@/components/offline/load-pack-button";
|
||||
@@ -162,6 +163,16 @@ function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColony
|
||||
/>
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
<ColonyFacts colony={colony} />
|
||||
{/* A pack from before hives says nothing about them: no section then. */}
|
||||
{pack.hives ? (
|
||||
<OfflineSection title="Beute">
|
||||
<OfflineHive
|
||||
colonyId={colony.id}
|
||||
setup={pack.hives[colony.id] ?? null}
|
||||
systems={pack.hiveCatalog ?? []}
|
||||
/>
|
||||
</OfflineSection>
|
||||
) : null}
|
||||
{pack.queens !== null ? <QueenFacts queen={queen} /> : null}
|
||||
{pack.inspections !== null ? (
|
||||
<LastInspections inspections={pack.inspections} colonyId={colony.id} />
|
||||
|
||||
@@ -3,8 +3,10 @@ import { render, screen } from "@testing-library/react";
|
||||
import SettingsLayout from "./layout";
|
||||
|
||||
const replaceMock = vi.fn();
|
||||
let mockPathname = "/settings/system";
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: replaceMock }),
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
let mockUser: { permissions: string[] } | null = null;
|
||||
@@ -16,6 +18,7 @@ describe("SettingsLayout", () => {
|
||||
beforeEach(() => {
|
||||
replaceMock.mockReset();
|
||||
mockUser = null;
|
||||
mockPathname = "/settings/system";
|
||||
});
|
||||
|
||||
it("renders children when the user has a settings-area permission", () => {
|
||||
@@ -67,4 +70,32 @@ describe("SettingsLayout", () => {
|
||||
expect(screen.queryByText("Einstellungen-Inhalt")).not.toBeInTheDocument();
|
||||
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
|
||||
it("lets every signed-in user read the voice and privacy page", () => {
|
||||
mockUser = { permissions: [] };
|
||||
mockPathname = "/settings/voice";
|
||||
|
||||
render(
|
||||
<SettingsLayout>
|
||||
<p>Einstellungen-Inhalt</p>
|
||||
</SettingsLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not open other settings pages through the voice exception", () => {
|
||||
mockUser = { permissions: [] };
|
||||
mockPathname = "/settings/voice-admin";
|
||||
|
||||
render(
|
||||
<SettingsLayout>
|
||||
<p>Einstellungen-Inhalt</p>
|
||||
</SettingsLayout>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Einstellungen-Inhalt")).not.toBeInTheDocument();
|
||||
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
const SETTINGS_AREA_PERMISSIONS = [
|
||||
@@ -23,12 +23,19 @@ const SETTINGS_AREA_PERMISSIONS = [
|
||||
"system:read",
|
||||
];
|
||||
|
||||
/** Pages under /settings that every signed-in user may open (no admin permission). */
|
||||
const OPEN_SETTINGS_PATHS = ["/settings/voice"];
|
||||
|
||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
const hasSettingsAccess = SETTINGS_AREA_PERMISSIONS.some((permission) =>
|
||||
user?.permissions.includes(permission),
|
||||
const isOpenPage = OPEN_SETTINGS_PATHS.some(
|
||||
(path) => pathname === path || pathname.startsWith(`${path}/`),
|
||||
);
|
||||
const hasSettingsAccess =
|
||||
(isOpenPage && Boolean(user)) ||
|
||||
SETTINGS_AREA_PERMISSIONS.some((permission) => user?.permissions.includes(permission));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSettingsAccess) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import axe from "axe-core";
|
||||
import VoiceSettingsPage from "./page";
|
||||
import { getCloudRecognitionConsent } from "@/lib/voice/consent";
|
||||
import { fetchSiteSettings } from "@/lib/site-settings/api";
|
||||
import type { SiteSettings } from "@/lib/site-settings/types";
|
||||
|
||||
let permissions = new Set<string>();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
|
||||
}));
|
||||
vi.mock("@/lib/site-settings/api", () => ({ fetchSiteSettings: vi.fn() }));
|
||||
|
||||
function settings(overrides: Partial<SiteSettings> = {}): SiteSettings {
|
||||
return {
|
||||
id: "s-1",
|
||||
youtubeUrl: null,
|
||||
instagramUrl: null,
|
||||
tiktokUrl: null,
|
||||
websiteUrl: null,
|
||||
holderName: "Imkerei Muster",
|
||||
holderAddress: "Honigweg 1, 12345 Bienenstadt",
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
originCountry: "DE",
|
||||
updatedAt: "2026-10-16T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<VoiceSettingsPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("VoiceSettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
permissions = new Set();
|
||||
vi.mocked(fetchSiteSettings).mockReset().mockResolvedValue(settings());
|
||||
});
|
||||
|
||||
it("the cloud switch is off by default and stores the consent", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: /Browser-Cloud-Erkennung erlauben/ });
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(getCloudRecognitionConsent()).toBe(false);
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(toggle).toBeChecked();
|
||||
expect(getCloudRecognitionConsent()).toBe(true);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Erlaubt");
|
||||
|
||||
await user.click(toggle);
|
||||
expect(getCloudRecognitionConsent()).toBe(false);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Aus");
|
||||
});
|
||||
|
||||
it("shows a stored consent as switched on", async () => {
|
||||
window.localStorage.setItem("apicore.voice.cloud-consent", "1");
|
||||
|
||||
renderPage();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("switch", { name: /Browser-Cloud-Erkennung/ })).toBeChecked(),
|
||||
);
|
||||
});
|
||||
|
||||
it("names the retention periods in words", () => {
|
||||
renderPage();
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("24 Stunden");
|
||||
expect(text).toContain("7 Tage");
|
||||
expect(text).toContain("90 Tage");
|
||||
expect(text).toContain("Anthropic");
|
||||
expect(text).toContain("Google");
|
||||
expect(text).toContain("Apple");
|
||||
expect(text).toContain("im Cluster");
|
||||
});
|
||||
|
||||
it("shows the holder name and address from the site settings", async () => {
|
||||
permissions = new Set(["site-settings:read"]);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Imkerei Muster")).toBeInTheDocument();
|
||||
expect(screen.getByText("Honigweg 1, 12345 Bienenstadt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a placeholder without the permission or while nothing is stored", async () => {
|
||||
renderPage();
|
||||
expect(fetchSiteSettings).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByText(/Noch nicht hinterlegt/)).toHaveLength(2);
|
||||
|
||||
permissions = new Set(["site-settings:read"]);
|
||||
vi.mocked(fetchSiteSettings).mockResolvedValue(
|
||||
settings({ holderName: null, holderAddress: null }),
|
||||
);
|
||||
renderPage();
|
||||
await waitFor(() => expect(fetchSiteSettings).toHaveBeenCalled());
|
||||
expect(screen.getAllByText(/Noch nicht hinterlegt/)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("axe finds nothing", async () => {
|
||||
permissions = new Set(["site-settings:read"]);
|
||||
const { container } = renderPage();
|
||||
await screen.findByText("Imkerei Muster");
|
||||
|
||||
// jsdom has no layout or colours: the contrast rule cannot run here.
|
||||
const results = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchSiteSettings } from "@/lib/site-settings/api";
|
||||
import {
|
||||
getCloudRecognitionConsent,
|
||||
setCloudRecognitionConsent,
|
||||
subscribeCloudRecognitionConsent,
|
||||
} from "@/lib/voice/consent";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
RECORDING_NOTICE,
|
||||
VOICE_PRIVACY_SECTIONS,
|
||||
} from "./voice-privacy-text";
|
||||
|
||||
const NOT_STORED = "Noch nicht hinterlegt – der Inhaber trägt das unter Verwaltung ein.";
|
||||
|
||||
function CloudConsentCard() {
|
||||
// localStorage does not exist on the server: it renders "off" and the client corrects it.
|
||||
const allowed = useSyncExternalStore(
|
||||
subscribeCloudRecognitionConsent,
|
||||
getCloudRecognitionConsent,
|
||||
() => false,
|
||||
);
|
||||
|
||||
function toggle(next: boolean) {
|
||||
setCloudRecognitionConsent(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<h2 className="font-heading text-lg font-semibold">Browser-Cloud-Erkennung</h2>
|
||||
<label
|
||||
htmlFor="voice-cloud-consent"
|
||||
className="flex min-h-14 cursor-pointer items-center justify-between gap-4 rounded-xl border border-input px-4 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium">Browser-Cloud-Erkennung erlauben</span>
|
||||
<input
|
||||
id="voice-cloud-consent"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
className="size-6 shrink-0 accent-[var(--primary)]"
|
||||
checked={allowed}
|
||||
onChange={(event) => toggle(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p role="status" className="text-sm font-medium">
|
||||
{allowed ? "Erlaubt" : "Aus"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ohne Erlaubnis diktierst du nur, wenn dein Gerät die Sprache selbst erkennt. Du kannst
|
||||
die Erlaubnis jederzeit hier zurücknehmen.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function HolderCard() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canRead = hasPermission("site-settings:read");
|
||||
const { data } = useQuery({
|
||||
queryKey: ["site-settings"],
|
||||
queryFn: fetchSiteSettings,
|
||||
enabled: canRead,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
<h2 className="font-heading text-lg font-semibold">Verantwortlich</h2>
|
||||
<p className="text-sm text-muted-foreground">Name und Anschrift:</p>
|
||||
<p className="text-sm font-medium">{data?.holderName || NOT_STORED}</p>
|
||||
<p className="text-sm font-medium">{data?.holderAddress || NOT_STORED}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VoiceSettingsPage() {
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={ShieldCheck}
|
||||
title="Sprache & Datenschutz"
|
||||
description="Was mit deiner Stimme und dem Text passiert."
|
||||
/>
|
||||
<CloudConsentCard />
|
||||
{VOICE_PRIVACY_SECTIONS.map((section) => (
|
||||
<Card key={section.id}>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<h2 className="font-heading text-lg font-semibold">{section.title}</h2>
|
||||
{section.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="text-sm">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<h2 className="font-heading text-lg font-semibold">Vor der ersten Aufnahme</h2>
|
||||
<p className="text-sm">{RECORDING_NOTICE}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<HolderCard />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* What the app does with voice and text, in plain German (T7 spec 8.4 and 9.5).
|
||||
* Kept apart from the page so the wording can be checked and changed in one
|
||||
* place. Retention periods are spelled out; the server enforces them in SQL.
|
||||
*/
|
||||
export const VOICE_RETENTION = {
|
||||
audio: "24 Stunden",
|
||||
content: "7 Tage",
|
||||
record: "90 Tage",
|
||||
} as const;
|
||||
|
||||
export interface PrivacySection {
|
||||
id: string;
|
||||
title: string;
|
||||
paragraphs: readonly string[];
|
||||
}
|
||||
|
||||
/** Shown once before the first recording and kept here for reading later. */
|
||||
export const RECORDING_NOTICE =
|
||||
"Sprich nur selbst und nenne keine Namen anderer Personen. Der Ton bleibt in deinem Cluster und wird nach dem Abschreiben gelöscht. Der Text geht zur Auswertung an Anthropic.";
|
||||
|
||||
export const VOICE_PRIVACY_SECTIONS: readonly PrivacySection[] = [
|
||||
{
|
||||
id: "dictation",
|
||||
title: "Diktat in den Textfeldern",
|
||||
paragraphs: [
|
||||
"Dein Browser wandelt deine Sprache in Text um. Die App speichert dabei keinen Ton.",
|
||||
"Wenn dein Gerät das kann, läuft die Erkennung auf dem Gerät. Dann verlässt kein Ton das Gerät.",
|
||||
"Wenn nicht, schickt der Browser den Ton an den Hersteller: bei Chrome an Google, bei Safari an Apple. Das passiert nur, wenn du es unten erlaubst.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
title: "Sprachnotizen (in Vorbereitung)",
|
||||
paragraphs: [
|
||||
`Der Ton bleibt in deinem Cluster. Er wird nach dem Abschreiben gelöscht, spätestens nach ${VOICE_RETENTION.audio}.`,
|
||||
`Der abgeschriebene Text geht zur Auswertung an Anthropic. Er bleibt, bis du ihn übernimmst oder verwirfst, höchstens ${VOICE_RETENTION.content}.`,
|
||||
`Angaben wie Status und Zähler bleiben ${VOICE_RETENTION.record}. Danach löscht das System die Zeile.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "where",
|
||||
title: "Wohin geht was",
|
||||
paragraphs: [
|
||||
"Ton von Sprachnotizen: bleibt im Cluster.",
|
||||
"Text zur Auswertung: geht an Anthropic. Die Verarbeitung findet voraussichtlich in den USA statt.",
|
||||
"Browser-Diktat mit Cloud-Erkennung: geht an Google oder Apple.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "limits",
|
||||
title: "Grenzen",
|
||||
paragraphs: [
|
||||
"Es spricht eine Person. Sprich nur selbst.",
|
||||
"In den täglichen Sicherungen liegen gelöschte Einträge noch einige Tage verschlüsselt, bis die alte Sicherung ersetzt wird.",
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user