diff --git a/ApiCore-Frontend/public/sw-routes.js b/ApiCore-Frontend/public/sw-routes.js
index 0248006..a91579b 100644
--- a/ApiCore-Frontend/public/sw-routes.js
+++ b/ApiCore-Frontend/public/sw-routes.js
@@ -24,6 +24,8 @@
"/treatments/new",
"/feedings/new",
"/swarm-control/new",
+ "/voice",
+ "/voice/new",
];
var OFFLINE_PAGE = "/offline";
@@ -54,6 +56,10 @@
// apiaries themselves: exact coordinates and the address (theft risk)
/\/api\/v1\/apiaries\/?$/i,
/\/api\/v1\/apiaries\/[^/]+\/?$/i,
+ // voice notes: transcripts and suggestions are content (health of a
+ // person's colonies, spoken words); the list, the detail and the status
+ // are current state or nothing - the device queue keeps the unsent audio
+ /\/api\/v1\/voice-captures(\/|$)/i,
// exports and backups: full records incl. coordinates and addresses,
// names (Bestandsbuch), megabytes
/\/api\/v1\/export\//i,
diff --git a/ApiCore-Frontend/public/sw.js b/ApiCore-Frontend/public/sw.js
index 6ecf5eb..588a5a6 100644
--- a/ApiCore-Frontend/public/sw.js
+++ b/ApiCore-Frontend/public/sw.js
@@ -21,7 +21,7 @@
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex
// characters; sw-routes.spec.ts checks it). A changed value changes this
// file, so every browser - Safari too - installs the worker with new rules.
-const SW_ROUTES_VERSION = "215afb9f84e9";
+const SW_ROUTES_VERSION = "adc0d618eac9";
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
const {
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts
index 2e9d389..7d50bdd 100644
--- a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.spec.ts
@@ -235,6 +235,24 @@ describe("toCreateInspectionInput with hive ops", () => {
});
});
+describe("toCreateInspectionInput with a voice note", () => {
+ const values = { ...initialInspectionValues("c-1"), date: "2026-10-01" };
+
+ it("carries voiceCaptureId only when there is a note, for any permission", () => {
+ expect(toCreateInspectionInput(values, true, [], "cap-1").voiceCaptureId).toBe("cap-1");
+ expect(toCreateInspectionInput(values, false, [], "cap-1").voiceCaptureId).toBe("cap-1");
+ expect(toCreateInspectionInput(values, true, [], undefined)).not.toHaveProperty(
+ "voiceCaptureId",
+ );
+ expect(toCreateInspectionInput(values, true)).not.toHaveProperty("voiceCaptureId");
+ });
+
+ it("keeps the note out of the form values and so out of the draft", () => {
+ expect(initialInspectionValues("c-1")).not.toHaveProperty("voiceCaptureId");
+ expect(toInspectionInput(values)).not.toHaveProperty("voiceCaptureId");
+ });
+});
+
describe("feedStatusOptions", () => {
const labels = (stored: Parameters[0]) =>
feedStatusOptions(stored).map((option) => option.label);
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts
index c18c6cb..d4c5ec7 100644
--- a/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/inspection-form-values.ts
@@ -240,22 +240,28 @@ 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 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.
+ * hive ops collected in the „Beute“ section, and the voice note the answers
+ * were reviewed from. All are omitted when empty, so nothing is asked of the
+ * server that was not wanted. The voice note id is not a form value: it never
+ * enters a draft.
*/
export function toCreateInspectionInput(
values: InspectionFormValues,
canUpdateColony: boolean,
hiveOps: HiveOp[] = [],
+ voiceCaptureId?: string,
): CreateInspectionInput {
- const input: CreateInspectionInput = toInspectionInput(values);
+ const base: CreateInspectionInput = {
+ ...toInspectionInput(values),
+ ...(voiceCaptureId ? { voiceCaptureId } : {}),
+ };
if (!canUpdateColony) {
- return input;
+ return base;
}
const filled = new Set(takeoverFieldsFilled(values));
const applyToColony = values.applyToColony.filter((field) => filled.has(field));
return {
- ...input,
+ ...base,
...(applyToColony.length > 0 ? { applyToColony } : {}),
...(hiveOps.length > 0 ? { hiveChange: { ops: hiveOps } } : {}),
};
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page-voice.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page-voice.spec.tsx
new file mode 100644
index 0000000..2a31c72
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page-voice.spec.tsx
@@ -0,0 +1,565 @@
+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 axe from "axe-core";
+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 { fetchApiaryWeather } from "@/lib/weather/api";
+import {
+ type VoiceCaptureDetail,
+ type VoiceSuggestion,
+ discardVoiceCapture,
+ fetchVoiceCapture,
+ fetchVoiceConfig,
+ voiceKeys,
+} from "@/lib/voice/api";
+import { syncPendingEntries } from "@/lib/offline/sync";
+
+// The voice note in the inspection form (Task 20): suggestions, take-over, save with the note.
+
+const push = vi.fn();
+let search = "colonyId=colony-1&voice=cap-1";
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+ useSearchParams: () => new URLSearchParams(search),
+}));
+let canUpdateColony = false;
+vi.mock("@/lib/auth/auth-context", () => ({
+ useCurrentUserId: () => "user-1",
+ useAuth: () => ({ hasPermission: () => canUpdateColony }),
+}));
+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("@/lib/voice/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchVoiceCapture: vi.fn(),
+ discardVoiceCapture: vi.fn(),
+ fetchVoiceConfig: vi.fn(),
+}));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
+vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null }));
+vi.mock("@/lib/hive/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ 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,
+ 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),
+}));
+
+function suggestion(
+ field: string,
+ value: VoiceSuggestion["value"],
+ quote: string,
+ extra: Partial = {},
+): VoiceSuggestion {
+ return { field, value, quote, corrected: false, truncated: false, ...extra };
+}
+
+function note(extra: Partial = {}): VoiceCaptureDetail {
+ return {
+ id: "cap-1",
+ colonyId: "colony-1",
+ colonyName: "V-01",
+ status: "READY",
+ source: "AUDIO",
+ createdAt: "2026-10-12T08:00:00.000Z",
+ lastErrorCode: null,
+ contentExpiresAt: "2026-10-19T08:00:00.000Z",
+ inspectionDate: "2026-10-12",
+ transcript: "Königin hab ich gesehen, sehr sanft, Futter niedrig.",
+ suggestions: [
+ suggestion("queenSeen", true, "Königin hab ich gesehen"),
+ suggestion("gentleness", 5, "sehr sanft"),
+ suggestion("feedStatus", "WEAK", "Futter niedrig"),
+ ],
+ unclear: [],
+ dropped: 0,
+ ...extra,
+ };
+}
+
+let client: QueryClient;
+
+function renderPage() {
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+const radio = (group: string | RegExp, name: string) =>
+ within(screen.getByRole("radiogroup", { name: group })).getByRole("radio", { name });
+
+/** The row of one field: found by the name its group gets. */
+const row = (fieldLabel: string) =>
+ screen.getByRole("group", { name: `Vorschlag aus Sprache: ${fieldLabel}` });
+
+async function loaded() {
+ await screen.findByRole("heading", { name: /^Sprachnotiz vom/ });
+}
+
+const SAVE = { name: "Durchsicht speichern" };
+
+describe("inspection form with a voice note", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ search = "colonyId=colony-1&voice=cap-1";
+ canUpdateColony = false;
+ push.mockReset();
+ vi.mocked(fetchColonies).mockResolvedValue([
+ { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
+ ] as never);
+ vi.mocked(createInspection).mockReset().mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
+ vi.mocked(fetchColonyInspections).mockResolvedValue([]);
+ vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
+ vi.mocked(fetchVoiceCapture).mockReset().mockResolvedValue(note());
+ vi.mocked(discardVoiceCapture).mockReset().mockResolvedValue(undefined);
+ vi.mocked(fetchVoiceConfig).mockResolvedValue({
+ recordingEnabled: true,
+ extractionEnabled: true,
+ model: "m",
+ retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
+ });
+ vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
+ vi.mocked(toast.info).mockReset();
+ vi.mocked(toast.success).mockReset();
+ });
+
+ it("takes colony and date from the loaded note and fills no field", async () => {
+ renderPage();
+ await loaded();
+
+ expect(screen.getByLabelText(/^Datum/)).toHaveValue("2026-10-12");
+ expect(fetchVoiceCapture).toHaveBeenCalledWith("cap-1");
+ });
+
+ it("leaves the field empty until Übernehmen", async () => {
+ renderPage();
+ await loaded();
+
+ expect(radio("Königin gesehen", "Nicht geprüft")).toHaveAttribute("aria-checked", "true");
+ expect(radio(/^Sanftmut/, "5 von 5")).toHaveAttribute("aria-checked", "false");
+ expect(within(row("Königin gesehen")).getByText("Königin gesehen: Ja")).toBeInTheDocument();
+ expect(within(row("Königin gesehen")).getByText(/„Königin hab ich gesehen“/)).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Sprachnotiz vom 12.10., 3 Vorschläge" })).toBeInTheDocument();
+ });
+
+ it("Übernehmen sets the value and moves no focus", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ const take = within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ });
+ take.focus();
+ await user.keyboard("{Enter}");
+
+ expect(radio("Königin gesehen", "Ja")).toHaveAttribute("aria-checked", "true");
+ expect(within(row("Königin gesehen")).getByRole("status")).toHaveTextContent("Übernommen");
+ expect(within(row("Königin gesehen")).getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
+ });
+
+ it("Rückgängig puts the earlier answer back", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ }));
+ await user.click(within(row("Königin gesehen")).getByRole("button", { name: /^Rückgängig/ }));
+
+ expect(radio("Königin gesehen", "Nicht geprüft")).toHaveAttribute("aria-checked", "true");
+ expect(within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ })).toBeInTheDocument();
+ });
+
+ it("Ändern focuses the field", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(within(row("Sanftmut")).getByRole("button", { name: /^Ändern/ }));
+
+ await waitFor(() => expect(radio(/^Sanftmut/, "5 von 5")).toHaveFocus());
+ expect(radio(/^Sanftmut/, "5 von 5")).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("a value changed by hand is the beekeeper's own again", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(within(row("Sanftmut")).getByRole("button", { name: /^Übernehmen/ }));
+ await user.click(radio(/^Sanftmut/, "3 von 5"));
+
+ expect(screen.queryByRole("group", { name: "Vorschlag aus Sprache: Sanftmut" })).not.toBeInTheDocument();
+ });
+
+ it("Ignorieren puts a suggestion aside and sends nothing for it", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(within(row("Sanftmut")).getByRole("button", { name: /^Ignorieren/ }));
+ await user.click(screen.getByRole("button", SAVE));
+
+ expect(within(row("Sanftmut")).getByRole("status")).toHaveTextContent("Ignoriert");
+ const [input] = vi.mocked(createInspection).mock.calls[0];
+ expect(input.gentleness).toBeNull();
+ });
+
+ it("Alle übernehmen asks first and names the count", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
+ const dialog = screen.getByRole("alertdialog");
+ expect(within(dialog).getByText("3 Felder aus der Sprachnotiz übernehmen?")).toBeInTheDocument();
+ // The dialog hides the form from assistive technology; nothing changed behind it.
+ expect(
+ within(screen.getByRole("radiogroup", { name: "Königin gesehen", hidden: true })).getByRole(
+ "radio",
+ { name: "Nicht geprüft", hidden: true },
+ ),
+ ).toHaveAttribute("aria-checked", "true");
+ await user.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
+
+ expect(radio("Königin gesehen", "Ja")).toHaveAttribute("aria-checked", "true");
+ expect(radio(/^Sanftmut/, "5 von 5")).toHaveAttribute("aria-checked", "true");
+ expect(radio(/^Futterstatus/, "Niedrig")).toHaveAttribute("aria-checked", "true");
+ expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
+ });
+
+ it("takes a feed level the three buttons do not offer", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({ suggestions: [suggestion("feedStatus", "EXCELLENT", "Futter reichlich")] }),
+ );
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(within(row("Futter")).getByRole("button", { name: /^Übernehmen/ }));
+
+ expect(radio(/^Futterstatus/, "Reichlich")).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("works with the keyboard only", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ const take = within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ });
+ for (let tabs = 0; tabs < 40 && document.activeElement !== take; tabs += 1) {
+ await user.tab();
+ }
+ expect(take).toHaveFocus();
+ await user.keyboard("{Enter}");
+
+ expect(radio("Königin gesehen", "Ja")).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("both hints stand one below the other and neither fills the field", async () => {
+ vi.mocked(fetchColonyInspections).mockResolvedValue([
+ {
+ id: "last",
+ colonyId: "colony-1",
+ userId: "u",
+ date: "2026-10-01T00:00:00.000Z",
+ queenSeen: false,
+ } as never,
+ ]);
+ renderPage();
+ await loaded();
+
+ const previous = await screen.findByText(/Letztes Mal: Nein/);
+ const voice = row("Königin gesehen");
+ expect(previous.compareDocumentPosition(voice) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(radio("Königin gesehen", "Nicht geprüft")).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("adds free text to the field and opens the folded section for it", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({ suggestions: [suggestion("measures", "Honigraum aufgesetzt", "Honigraum drauf")] }),
+ );
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ expect(screen.getByRole("button", { name: /Weitere Angaben/ })).toHaveAttribute("aria-expanded", "true");
+ await user.click(within(row("Maßnahmen")).getByRole("button", { name: /^Übernehmen/ }));
+
+ expect(screen.getByLabelText(/^Maßnahmen/)).toHaveValue("Honigraum aufgesetzt");
+ });
+
+ it("saves with voiceCaptureId and shows no suggestion afterwards", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ }));
+ await user.click(screen.getByRole("button", SAVE));
+
+ await waitFor(() => expect(push).toHaveBeenCalledWith("/colonies/colony-1"));
+ const [input] = vi.mocked(createInspection).mock.calls[0];
+ expect(input).toMatchObject({ voiceCaptureId: "cap-1", queenSeen: true, gentleness: null });
+ expect(client.getQueryState(voiceKeys.detail("cap-1"))?.isInvalidated).toBe(true);
+ });
+
+ it("tells the beekeeper when the note was gone and the inspection was saved without it", async () => {
+ vi.mocked(createInspection).mockResolvedValue({
+ id: "i-1",
+ colonyId: "colony-1",
+ voiceCaptureGone: true,
+ } as never);
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(screen.getByRole("button", SAVE));
+
+ await waitFor(() =>
+ expect(toast.info).toHaveBeenCalledWith(
+ "Sprachnotiz war abgelaufen, Durchsicht ohne sie gespeichert",
+ ),
+ );
+ });
+
+ it("a note of another colony offers to save without it and does not retry by itself", async () => {
+ vi.mocked(createInspection).mockRejectedValueOnce(
+ new ApiError(409, "Die Sprachnotiz gehört zu einem anderen Volk.", {
+ code: "VOICE_CAPTURE_COLONY_MISMATCH",
+ }),
+ );
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(screen.getByRole("button", SAVE));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(/anderen Volk/);
+ expect(createInspection).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByRole("button", { name: "Ohne Sprachnotiz speichern" }));
+
+ await waitFor(() => expect(createInspection).toHaveBeenCalledTimes(2));
+ expect(vi.mocked(createInspection).mock.calls[1][0]).not.toHaveProperty("voiceCaptureId");
+ });
+
+ it("Sprachnotiz verwerfen discards the note and saves without it", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(screen.getByRole("button", { name: "Sprachnotiz verwerfen" }));
+ await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }));
+
+ await waitFor(() => expect(discardVoiceCapture).toHaveBeenCalledWith("cap-1"));
+ await waitFor(() =>
+ expect(screen.queryByRole("heading", { name: /^Sprachnotiz vom/ })).not.toBeInTheDocument(),
+ );
+ await user.click(screen.getByRole("button", SAVE));
+ expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("voiceCaptureId");
+ });
+
+ it("a transcribed note without evaluation shows the text and still ties the note to the save", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({ status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED", suggestions: null }),
+ );
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
+ expect(screen.getByText(/Königin hab ich gesehen, sehr sanft/)).toBeVisible();
+ expect(screen.queryByRole("group", { name: /^Vorschlag aus Sprache/ })).not.toBeInTheDocument();
+ await user.click(radio("Königin gesehen", "Ja"));
+ await user.click(screen.getByRole("button", SAVE));
+
+ expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({
+ voiceCaptureId: "cap-1",
+ queenSeen: true,
+ });
+ });
+
+ it("a failed note with text is read the same way", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({ status: "FAILED", lastErrorCode: "EXTRACT_FAILED", suggestions: null }),
+ );
+ renderPage();
+ await loaded();
+
+ expect(screen.getByText(/Königin hab ich gesehen, sehr sanft/)).toBeVisible();
+ });
+
+ it("does not send a note that is gone", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({ status: "CONFIRMED", suggestions: null, transcript: null }),
+ );
+ const user = userEvent.setup();
+ renderPage();
+ expect(await screen.findByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", SAVE));
+
+ expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("voiceCaptureId");
+ });
+
+ it("does not send a note that cannot be found", async () => {
+ vi.mocked(fetchVoiceCapture).mockRejectedValue(new ApiError(404, "nicht gefunden"));
+ const user = userEvent.setup();
+ renderPage();
+
+ expect(await screen.findByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
+ await user.click(screen.getByRole("button", SAVE));
+
+ expect(vi.mocked(createInspection).mock.calls[0][0]).not.toHaveProperty("voiceCaptureId");
+ });
+
+ it("keeps working when the note cannot be loaded, and still sends its id", async () => {
+ vi.mocked(fetchVoiceCapture).mockRejectedValue(new Error("offline"));
+ const user = userEvent.setup();
+ renderPage();
+
+ expect(await screen.findByText(/Die Sprachnotiz konnte nicht geladen werden/)).toBeInTheDocument();
+ await user.click(screen.getByRole("button", SAVE));
+
+ expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ voiceCaptureId: "cap-1" });
+ });
+
+ it("ignores a suggestion for a field the form does not have", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({ suggestions: [suggestion("weather", "sonnig", "sonnig"), suggestion("gentleness", 99, "x")] }),
+ );
+ renderPage();
+ await loaded();
+
+ expect(screen.queryByRole("group", { name: /^Vorschlag aus Sprache/ })).not.toBeInTheDocument();
+ });
+
+ it("follows the colony of the note when the address names another", async () => {
+ vi.mocked(fetchColonies).mockResolvedValue([
+ { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
+ { id: "colony-2", code: "V-02", status: "ACTIVE", apiaryId: "apiary-1" },
+ ] as never);
+ search = "colonyId=colony-2&voice=cap-1";
+ const user = userEvent.setup();
+ renderPage();
+
+ // The note says colony-1: the form follows it.
+ await loaded();
+ expect(screen.queryByText(/gehört zum Volk/)).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", SAVE));
+ expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({
+ colonyId: "colony-1",
+ voiceCaptureId: "cap-1",
+ });
+ });
+
+ it("neither Übernehmen nor Alle übernehmen ticks the Volkskarte", async () => {
+ canUpdateColony = true;
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({
+ suggestions: [
+ suggestion("colonyStrength", 8, "acht Waben"),
+ suggestion("feedStatus", "WEAK", "Futter niedrig"),
+ suggestion("broodPattern", 4, "Brutbild gut"),
+ ],
+ }),
+ );
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(within(row("Volksstärke")).getByRole("button", { name: /^Übernehmen/ }));
+ await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
+ await user.click(
+ within(screen.getByRole("alertdialog")).getByRole("button", { name: "Übernehmen" }),
+ );
+ await user.click(screen.getByRole("button", SAVE));
+
+ const input = vi.mocked(createInspection).mock.calls[0][0];
+ expect(input).toMatchObject({ colonyStrength: 8, feedStatus: "WEAK", broodPattern: 4 });
+ expect(input).not.toHaveProperty("applyToColony");
+ });
+
+ it("text-only when the evaluation is off: no waiting sentence, the text is open", async () => {
+ vi.mocked(fetchVoiceConfig).mockResolvedValue({
+ recordingEnabled: true,
+ extractionEnabled: false,
+ model: "m",
+ retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
+ });
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({ status: "TRANSCRIBED", suggestions: null }),
+ );
+ renderPage();
+ await loaded();
+
+ expect(await screen.findByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
+ expect(screen.queryByText("Wird noch ausgewertet")).not.toBeInTheDocument();
+ expect(screen.getByText(/Königin hab ich gesehen, sehr sanft/)).toBeVisible();
+ });
+
+ it("focuses the box after Alle übernehmen and the first field after discarding", async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await loaded();
+
+ await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
+ await user.click(
+ within(screen.getByRole("alertdialog")).getByRole("button", { name: "Übernehmen" }),
+ );
+ await waitFor(() =>
+ expect(screen.getByRole("region", { name: /^Sprachnotiz vom/ })).toHaveFocus(),
+ );
+
+ await user.click(screen.getByRole("button", { name: "Sprachnotiz verwerfen" }));
+ await user.click(
+ within(screen.getByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
+ );
+ await waitFor(() => expect(document.getElementById("inspection-colony")).toHaveFocus());
+ });
+
+ it("takes neither colony nor day from a note that is gone", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(
+ note({
+ status: "CONFIRMED",
+ suggestions: null,
+ transcript: null,
+ inspectionDate: "2020-01-02",
+ }),
+ );
+ renderPage();
+ expect(await screen.findByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
+
+ expect(screen.getByLabelText(/^Datum/)).not.toHaveValue("2020-01-02");
+ });
+
+ it("axe finds nothing", async () => {
+ const user = userEvent.setup();
+ const { container } = renderPage();
+ await loaded();
+ const run = () => axe.run(container, { rules: { "color-contrast": { enabled: false } } });
+
+ expect((await run()).violations).toEqual([]);
+ await user.click(within(row("Königin gesehen")).getByRole("button", { name: /^Übernehmen/ }));
+ expect((await run()).violations).toEqual([]);
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx
index 9a79741..74fb0e8 100644
--- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx
@@ -21,6 +21,8 @@ import {
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 { voiceKeys } from "@/lib/voice/api";
+import { INSPECTION_SCALE_DIRECTIONS, INSPECTION_SCALE_LABELS } from "@/lib/inspections/scales";
import type { HiveOp } from "@/lib/hive/types";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
@@ -62,11 +64,11 @@ import { StickySubmit } from "@/components/form/sticky-submit";
import { DraftBanner } from "@/components/form/draft-banner";
import { PHOTO_SESSION_QUERY_KEY, PhotoCaptureField } from "@/components/photos/photo-capture-field";
import {
- FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS,
InspectionFormValues,
currentDraftValues,
feedLegacyHint,
+ feedStatusOptions,
initialInspectionValues,
syncTakeoverSelection,
toCreateInspectionInput,
@@ -74,6 +76,8 @@ import {
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
import { suggestAfterInspection } from "./hive-save-follow-ups";
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
+import { isGoneVoiceStatus, isVoiceColonyMismatch, useVoiceReview } from "./use-voice-review";
+import { VoiceFieldRow, VoiceReviewBox } from "./voice-review";
/** Free-text fields that take dictation. */
type DictatedField = "notes" | "healthIssues" | "varroaIssues" | "measures";
@@ -82,6 +86,10 @@ const UNDO_TOAST_MS = 8000;
const WEATHER_UPDATED = "Wetter aktualisiert";
const WEATHER_MISSING = "Keine Wetterdaten für diesen Tag";
const PHOTOS_LATER = "Fotos werden später gesendet.";
+const VOICE_GONE_NOTICE = "Sprachnotiz war abgelaufen, Durchsicht ohne sie gespeichert";
+
+/** The free-text fields in „Weitere Angaben“, which is folded until a suggestion needs it. */
+const EXTRA_VOICE_FIELDS = ["honeySuperStatus", "varroaIssues", "healthIssues", "measures", "notes"];
const TAKEOVER_LABELS: Record = {
queenSeen: "Königin gesehen",
@@ -186,6 +194,8 @@ function LastInspectionBar({ last, onTakeOverAll }: LastInspectionBarProps) {
interface InspectionCaptureFormProps {
initialColonyId: string;
+ /** The voice note (`?voice=`) this inspection is reviewed from, if any. */
+ voiceId: string | null;
/** Apiary of the stand round this form was opened from, if any. */
roundApiaryId: string | null;
backTarget: { href: string; label: string };
@@ -204,6 +214,7 @@ function roundChipText(round: StandRound, colonyId: string): string {
function InspectionCaptureForm({
initialColonyId,
+ voiceId,
roundApiaryId,
backTarget,
}: InspectionCaptureFormProps) {
@@ -236,6 +247,39 @@ function InspectionCaptureForm({
const [submittedHiveOps, setSubmittedHiveOps] = useState("");
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
+ // A tap on „Übernehmen“ writes like any tap; observations are never ticked for the Volkskarte.
+ const voice = useVoiceReview({
+ voiceId,
+ initialColonyId,
+ values,
+ apply: (patch) => {
+ update((current) => ({ ...current, ...patch }), { autoTick: false });
+ setDirty(true);
+ },
+ });
+ const noteToApply = voice.capture;
+ const [appliedNoteId, setAppliedNoteId] = useState(null);
+ const [extraOpenedForVoice, setExtraOpenedForVoice] = useState(false);
+ if (!extraOpenedForVoice && EXTRA_VOICE_FIELDS.some((field) => voice.rowFor(field) !== null)) {
+ setExtraOpenedForVoice(true);
+ }
+
+ // Colony and day come from the note, once, as long as the beekeeper has not started on the form.
+ // A note that is gone says nothing about this inspection.
+ if (
+ noteToApply &&
+ appliedNoteId !== noteToApply.id &&
+ !dirty &&
+ !isGoneVoiceStatus(noteToApply.status)
+ ) {
+ setAppliedNoteId(noteToApply.id);
+ const day = noteToApply.inspectionDate.slice(0, 10);
+ update((current) => ({ ...current, colonyId: noteToApply.colonyId, date: day }), {
+ autoTick: false,
+ });
+ setHiveOps([]);
+ setHiveCardGeneration((generation) => generation + 1);
+ }
/**
* Every change of the answers also keeps the Volkskarte selection in step.
@@ -377,6 +421,11 @@ function InspectionCaptureForm({
queryKey: ["colonies", outcome.result.colonyId, "inspections"],
});
toast.success("Durchsicht gespeichert");
+ if (input.voiceCaptureId) {
+ // The server confirmed the note and deleted its content: lists must not show it as open.
+ void queryClient.invalidateQueries({ queryKey: voiceKeys.all, refetchType: "none" });
+ }
+ if (outcome.result.voiceCaptureGone) toast.info(VOICE_GONE_NOTICE);
if (input.hiveChange) {
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(input.colonyId) });
if (hiveBase && hiveCatalog) {
@@ -420,9 +469,19 @@ function InspectionCaptureForm({
document.getElementById("inspection-date")?.focus();
return;
}
+ submitInspection(voice.voiceCaptureId);
+ }
+
+ function submitInspection(voiceCaptureId: string | undefined) {
submittedPhotoSession.current = values.photoSessionId;
setSubmittedHiveOps(JSON.stringify(hiveOps));
- capture.submit(toCreateInspectionInput(values, canUpdateColony, hiveOps));
+ capture.submit(toCreateInspectionInput(values, canUpdateColony, hiveOps, voiceCaptureId));
+ }
+
+ /** „Ohne Sprachnotiz speichern“: after the server refused the note, the same entries go without it. */
+ function saveWithoutVoice() {
+ voice.leaveOut();
+ submitInspection(undefined);
}
/**
@@ -587,6 +646,9 @@ function InspectionCaptureForm({
onDiscard={discardDraft}
/>
) : null}
+
+
+
) : null}
{hintFor("feedStatus")}
+
>
)}
@@ -846,6 +916,7 @@ function InspectionCaptureForm({
collapsible
defaultOpen={false}
fieldCount={5}
+ forceOpen={extraOpenedForVoice}
>
{({ id }) => (
@@ -859,11 +930,13 @@ function InspectionCaptureForm({
}
/>
{hintFor("honeySuperStatus")}
+
>
)}
{({ id }) => (
+ <>
appendDictated("varroaIssues", text)}
@@ -877,10 +950,13 @@ function InspectionCaptureForm({
}
/>
+
+ >
)}
{({ id }) => (
+ <>
appendDictated("healthIssues", text)}
@@ -894,10 +970,13 @@ function InspectionCaptureForm({
}
/>
+
+ >
)}
{({ id }) => (
+ <>
appendDictated("measures", text)}
@@ -908,10 +987,13 @@ function InspectionCaptureForm({
onChange={(event) => set("measures", event.target.value)}
/>
+
+ >
)}
{({ id }) => (
+ <>
appendDictated("notes", text)}
@@ -922,6 +1004,8 @@ function InspectionCaptureForm({
onChange={(event) => set("notes", event.target.value)}
/>
+
+ >
)}
@@ -952,12 +1036,25 @@ function InspectionCaptureForm({
- {capture.submitError}
-
+
+
+ {capture.submitError}
+
+ {isVoiceColonyMismatch(capture.error) ? (
+
+ Ohne Sprachnotiz speichern
+
+ ) : null}
+
) : null
}
>
@@ -986,12 +1083,14 @@ export default function NewInspectionPage() {
);
const colonyId = searchParams.get("colonyId") ?? "";
const round = searchParams.get("round");
+ const voiceId = searchParams.get("voice");
- // A new colony or round remounts the form: fresh entry, fresh idempotency key.
+ // A new colony, round or voice note remounts the form: fresh entry, fresh idempotency key.
return (
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/use-voice-review.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/use-voice-review.spec.tsx
new file mode 100644
index 0000000..38a3a65
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/use-voice-review.spec.tsx
@@ -0,0 +1,98 @@
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fetchVoiceCapture, fetchVoiceConfig, type VoiceCaptureDetail } from "@/lib/voice/api";
+import { initialInspectionValues } from "./inspection-form-values";
+import { useVoiceReview } from "./use-voice-review";
+
+vi.mock("@/lib/voice/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchVoiceCapture: vi.fn(),
+ fetchVoiceConfig: vi.fn(),
+ discardVoiceCapture: vi.fn(),
+}));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+function note(status: VoiceCaptureDetail["status"]): VoiceCaptureDetail {
+ return {
+ id: "cap-1",
+ colonyId: "colony-1",
+ colonyName: "V-01",
+ status,
+ source: "AUDIO",
+ createdAt: "2026-10-12T08:00:00.000Z",
+ lastErrorCode: null,
+ contentExpiresAt: "2026-10-19T08:00:00.000Z",
+ inspectionDate: "2026-10-12",
+ transcript: "Text",
+ suggestions: null,
+ unclear: [],
+ dropped: 0,
+ };
+}
+
+const values = initialInspectionValues("colony-1");
+
+function mount() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ return renderHook(
+ () => useVoiceReview({ voiceId: "cap-1", initialColonyId: "colony-1", values, apply: vi.fn() }),
+ { wrapper },
+ );
+}
+
+const FIVE_SECONDS = 5000;
+const config = (extractionEnabled: boolean) =>
+ ({ recordingEnabled: true, extractionEnabled, model: "m", retention: {} }) as never;
+
+describe("useVoiceReview polling", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ vi.mocked(fetchVoiceConfig).mockResolvedValue(config(true));
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.mocked(fetchVoiceCapture).mockReset();
+ });
+
+ it("polling stops once the note is not in progress", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(note("EXTRACTING"));
+ mount();
+ await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 2 + 100);
+ expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBeGreaterThanOrEqual(2);
+
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(note("READY"));
+ await vi.advanceTimersByTimeAsync(FIVE_SECONDS + 100);
+ const afterReady = vi.mocked(fetchVoiceCapture).mock.calls.length;
+ await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 4);
+
+ expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(afterReady);
+ });
+
+ it("does not poll a transcribed note while the evaluation is switched off", async () => {
+ vi.mocked(fetchVoiceConfig).mockResolvedValue(config(false));
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(note("TRANSCRIBED"));
+ mount();
+ await vi.advanceTimersByTimeAsync(500);
+ const first = vi.mocked(fetchVoiceCapture).mock.calls.length;
+
+ await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 4);
+
+ expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(first);
+ });
+
+ it("gives up after ten minutes", async () => {
+ vi.mocked(fetchVoiceCapture).mockResolvedValue(note("EXTRACTING"));
+ mount();
+ await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + FIVE_SECONDS);
+ const atCap = vi.mocked(fetchVoiceCapture).mock.calls.length;
+
+ await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 6);
+
+ expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(atCap);
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/use-voice-review.ts b/ApiCore-Frontend/src/app/(app)/inspections/new/use-voice-review.ts
new file mode 100644
index 0000000..3c55708
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/use-voice-review.ts
@@ -0,0 +1,260 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { ApiError } from "@/lib/auth/api-client";
+import {
+ type VoiceCaptureDetail,
+ type VoiceSuggestion,
+ discardVoiceCapture,
+ fetchVoiceCapture,
+ fetchVoiceConfig,
+ voiceKeys,
+} from "@/lib/voice/api";
+import { isVoiceInProgress } from "@/lib/voice/status-texts";
+import {
+ describeFieldValue,
+ describeSuggestionValue,
+ isVoiceField,
+ openSuggestions,
+ suggestionPatch,
+ VOICE_FIELD_LABELS,
+} from "@/lib/voice/suggestions";
+import type { InspectionFormValues } from "./inspection-form-values";
+
+const POLL_MS = 5000;
+/** A note still unfinished after this long is not waited for any more; the page can be reopened. */
+const POLL_GIVE_UP_MS = 10 * 60 * 1000;
+const NOT_FOUND = 404;
+const GONE_STATUSES: readonly string[] = ["CONFIRMED", "DISCARDED", "EXPIRED"];
+
+/** Confirmed, discarded or expired: nothing of it is left to use. */
+export function isGoneVoiceStatus(status: string): boolean {
+ return GONE_STATUSES.includes(status);
+}
+
+type Settled = { kind: "taken"; before: unknown; after: unknown } | { kind: "ignored" };
+
+/** What the row under one field needs. */
+export interface VoiceFieldRowModel {
+ fieldLabel: string;
+ valueText: string;
+ quote: string;
+ chips: string[];
+ status: "open" | "taken" | "ignored";
+ onTakeOver: () => void;
+ onEdit: () => void;
+ onUndo: () => void;
+ onIgnore: () => void;
+ onRestore: () => void;
+}
+
+/** "Ohne Sprachnotiz speichern" is offered after this refusal. */
+export function isVoiceColonyMismatch(error: unknown): boolean {
+ return (
+ error instanceof ApiError &&
+ error.status === 409 &&
+ (error.body as { code?: unknown } | null)?.code === "VOICE_CAPTURE_COLONY_MISMATCH"
+ );
+}
+
+export interface UseVoiceReviewOptions {
+ /** `?voice=`; null when the form was not opened from a voice note. */
+ voiceId: string | null;
+ /** The colony in the address: the best guess for the note's colony until it has loaded. */
+ initialColonyId: string;
+ values: InspectionFormValues;
+ /** Writes into the form like a tap does (marks it changed, never ticks the Volkskarte). */
+ apply: (patch: Partial) => void;
+}
+
+function chipsOf(suggestion: VoiceSuggestion): string[] {
+ return [
+ ...(suggestion.corrected ? ["Korrigiert"] : []),
+ ...(suggestion.truncated ? ["Gekürzt"] : []),
+ ...(suggestion.previous
+ ? [`Zuvor genannt: ${describeFieldValue(suggestion.field, suggestion.previous.value)}`]
+ : []),
+ ];
+}
+
+/** The control of a field, found from its suggestion row: the row sits inside the field's block. */
+function focusFieldOf(field: string): void {
+ const row = document.querySelector(`[data-voice-row="${field}"]`);
+ const block = row?.parentElement;
+ if (!block) return;
+ const target = [...block.querySelectorAll(
+ 'input:not([disabled]), textarea:not([disabled]), [role="radio"][tabindex="0"]',
+ )].find((element) => !row.contains(element));
+ target?.focus();
+}
+
+/**
+ * The voice note behind `?voice=`, as the inspection form uses it: the
+ * loaded note, one row per suggestion with its state, and the box-wide
+ * choices. Nothing is applied by itself: every value reaches the form through
+ * a tap (`apply`). The id goes to the server only while the note is still
+ * open and belongs to the form's colony.
+ */
+export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseVoiceReviewOptions) {
+ const queryClient = useQueryClient();
+ const [settled, setSettled] = useState>(new Map());
+ const [leftOut, setLeftOut] = useState(false);
+
+ // Until the switch is known the evaluation counts as on: the box says "wird ausgewertet" at worst.
+ const config = useQuery({
+ queryKey: voiceKeys.config,
+ queryFn: fetchVoiceConfig,
+ enabled: voiceId !== null && !leftOut,
+ retry: false,
+ });
+ const extractionEnabled = config.data?.extractionEnabled ?? true;
+ const [startedAt] = useState(() => Date.now());
+
+ const query = useQuery({
+ queryKey: voiceKeys.detail(voiceId ?? ""),
+ queryFn: () => fetchVoiceCapture(voiceId ?? ""),
+ enabled: voiceId !== null && !leftOut,
+ retry: false,
+ refetchInterval: (state) =>
+ state.state.data &&
+ isVoiceInProgress(state.state.data.status, extractionEnabled) &&
+ Date.now() - startedAt < POLL_GIVE_UP_MS
+ ? POLL_MS
+ : false,
+ });
+ const capture: VoiceCaptureDetail | undefined = leftOut ? undefined : query.data;
+ const notFound = query.error instanceof ApiError && query.error.status === NOT_FOUND;
+ const isGone = capture ? isGoneVoiceStatus(capture.status) : notFound;
+ const noteColonyId = capture?.colonyId ?? initialColonyId;
+ const sameColony = values.colonyId === noteColonyId;
+ const suggestions = capture?.status === "READY" && sameColony ? (capture.suggestions ?? []) : [];
+
+ const settledFields = new Set(settled.keys());
+ const open = openSuggestions(suggestions, values, settledFields);
+
+ const valueOf = (field: string): unknown =>
+ (values as unknown as Record)[field];
+
+ function settle(field: string, entry: Settled) {
+ setSettled((current) => new Map(current).set(field, entry));
+ }
+
+ function unsettle(field: string) {
+ setSettled((current) => {
+ const next = new Map(current);
+ next.delete(field);
+ return next;
+ });
+ }
+
+ function takeOver(suggestion: VoiceSuggestion): Partial {
+ const patch = suggestionPatch(suggestion, values);
+ const field = suggestion.field;
+ if (!(field in patch)) return {};
+ apply(patch);
+ settle(field, {
+ kind: "taken",
+ before: valueOf(field),
+ after: (patch as Record)[field],
+ });
+ return patch;
+ }
+
+ function takeOverAll() {
+ const merged = open.reduce>(
+ (all, suggestion) => ({ ...all, ...suggestionPatch(suggestion, values) }),
+ {},
+ );
+ apply(merged);
+ setSettled((current) => {
+ const next = new Map(current);
+ for (const suggestion of open) {
+ next.set(suggestion.field, {
+ kind: "taken",
+ before: valueOf(suggestion.field),
+ after: (merged as Record)[suggestion.field],
+ });
+ }
+ return next;
+ });
+ }
+
+ function undo(field: string, entry: Settled) {
+ if (entry.kind === "taken" && valueOf(field) === entry.after) {
+ apply({ [field]: entry.before } as Partial);
+ }
+ unsettle(field);
+ }
+
+ function rowFor(field: string): VoiceFieldRowModel | null {
+ if (!isVoiceField(field)) return null;
+ const suggestion = suggestions.find((item) => item.field === field);
+ if (!suggestion) return null;
+ const entry = settled.get(field);
+ let status: VoiceFieldRowModel["status"] | null = null;
+ if (entry?.kind === "taken") {
+ // Edited by hand since: the value is the beekeeper's own now, no mark, no row.
+ status = valueOf(field) === entry.after ? "taken" : null;
+ } else if (entry?.kind === "ignored") {
+ status = "ignored";
+ } else if (open.includes(suggestion)) {
+ status = "open";
+ }
+ if (status === null) return null;
+ return {
+ fieldLabel: VOICE_FIELD_LABELS[field],
+ valueText: describeSuggestionValue(suggestion),
+ quote: suggestion.quote,
+ chips: chipsOf(suggestion),
+ status,
+ onTakeOver: () => {
+ takeOver(suggestion);
+ },
+ onEdit: () => {
+ if (entry?.kind !== "taken") takeOver(suggestion);
+ // After the commit: a roving-focus group only then knows which button is the chosen one.
+ window.setTimeout(() => focusFieldOf(field), 0);
+ },
+ onUndo: () => entry && undo(field, entry),
+ onIgnore: () => settle(field, { kind: "ignored" }),
+ onRestore: () => unsettle(field),
+ };
+ }
+
+ const discard = useMutation({
+ mutationFn: () => discardVoiceCapture(voiceId ?? ""),
+ onSuccess: () => {
+ setLeftOut(true);
+ toast.success("Sprachnotiz verworfen");
+ // The box and its buttons are gone: the focus goes to the first field of the form.
+ window.setTimeout(() => document.getElementById("inspection-colony")?.focus(), 0);
+ void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
+ },
+ onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
+ });
+
+ const sendsId = voiceId !== null && !leftOut && !isGone && sameColony;
+
+ return {
+ voiceId,
+ capture,
+ extractionEnabled,
+ isLoading: voiceId !== null && !leftOut && query.isPending,
+ loadFailed: voiceId !== null && !leftOut && query.isError && !notFound,
+ isGone: voiceId !== null && !leftOut && isGone,
+ sameColony,
+ /** The id to send with the inspection, if the note should be tied to it. */
+ voiceCaptureId: sendsId ? voiceId : undefined,
+ openCount: open.length,
+ rowFor,
+ takeOverAll,
+ discard: () => discard.mutate(),
+ discarding: discard.isPending,
+ /** „Ohne Sprachnotiz speichern“: the note stays, but this save no longer carries it. */
+ leaveOut: () => setLeftOut(true),
+ };
+}
+
+export type VoiceReview = ReturnType;
diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/voice-review.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/voice-review.tsx
new file mode 100644
index 0000000..1342956
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/inspections/new/voice-review.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { VoiceCaptureBox } from "@/components/voice/voice-capture-box";
+import { VoiceSuggestion } from "@/components/voice/voice-suggestion";
+import type { VoiceReview } from "./use-voice-review";
+
+const NOTE_CLASS = "rounded-xl bg-muted/50 p-3 text-sm";
+
+/**
+ * Above the form: the voice note behind `?voice=`. While it loads, when it
+ * cannot be read, when it is gone or belongs to another colony, one sentence
+ * says so - the form works as without a note in every one of these cases.
+ */
+export function VoiceReviewBox({ review }: { review: VoiceReview }) {
+ if (review.voiceId === null) return null;
+ if (review.isLoading) {
+ return (
+
+ Sprachnotiz wird geladen…
+
+ );
+ }
+ if (review.loadFailed) {
+ return (
+
+ Die Sprachnotiz konnte nicht geladen werden. Du kannst die Durchsicht trotzdem eintippen.
+
+ );
+ }
+ if (review.isGone) {
+ return (
+
+ Die Sprachnotiz gibt es nicht mehr. Du kannst die Durchsicht trotzdem speichern.
+
+ );
+ }
+ const { capture } = review;
+ if (!capture) return null;
+ if (!review.sameColony) {
+ return (
+
+ Die Sprachnotiz gehört zum Volk {capture.colonyName}. Für dieses Volk wird sie nicht
+ verwendet.
+
+ );
+ }
+ return (
+
+ );
+}
+
+/** The suggestion for one field, under the field and under its „Letztes Mal“ hint; nothing when there is none. */
+export function VoiceFieldRow({ review, field }: { review: VoiceReview; field: string }) {
+ const row = review.rowFor(field);
+ if (!row) return null;
+ return (
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/system/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/system/page.spec.tsx
index ad614f4..adff7c6 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/system/page.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/system/page.spec.tsx
@@ -14,6 +14,7 @@ import {
} from "@/lib/system/api";
import { JOB_NAMES, type SystemOverview } from "@/lib/system/types";
import { fetchNotificationDeliveries } from "@/lib/telegram/api";
+import { fetchVoiceConfig, fetchVoiceStats } from "@/lib/voice/api";
vi.mock("@/lib/system/api", () => ({
fetchSystemOverview: vi.fn(),
@@ -27,6 +28,11 @@ vi.mock("@/lib/telegram/api", () => ({
retryNotificationDelivery: vi.fn(),
retryFailedNotificationDeliveries: vi.fn(),
}));
+vi.mock("@/lib/voice/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchVoiceStats: vi.fn(),
+ fetchVoiceConfig: vi.fn(),
+}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { permissions } }),
@@ -99,6 +105,18 @@ describe("SystemPage", () => {
vi.mocked(fetchFailedWebhookDeliveries).mockResolvedValue([]);
vi.mocked(retryDomainEvent).mockResolvedValue(undefined);
vi.mocked(retryWebhookDelivery).mockResolvedValue(undefined);
+ vi.mocked(fetchVoiceStats).mockResolvedValue({
+ byStatus: { READY: 1 },
+ byError: {},
+ tokens: { input: 0, output: 0 },
+ byModel: {},
+ });
+ vi.mocked(fetchVoiceConfig).mockResolvedValue({
+ recordingEnabled: true,
+ extractionEnabled: true,
+ model: "claude-sonnet-5-5",
+ retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
+ });
vi.mocked(fetchNotificationDeliveries).mockResolvedValue({
telegram: {
botNameSet: true,
@@ -126,6 +144,20 @@ describe("SystemPage", () => {
expect(fetchNotificationDeliveries).not.toHaveBeenCalled();
});
+ it("shows the voice card only with system:manage (the stats endpoint needs it)", async () => {
+ renderPage();
+ expect(await screen.findByText("Sprachnotizen")).toBeInTheDocument();
+ });
+
+ it("does not show or load the voice card with system:read only", async () => {
+ permissions = ["system:read"];
+ renderPage();
+
+ await screen.findByText("Offen: 3");
+ expect(screen.queryByText("Sprachnotizen")).not.toBeInTheDocument();
+ expect(fetchVoiceStats).not.toHaveBeenCalled();
+ });
+
it("lists every job with its last run and status label", async () => {
renderPage();
@@ -137,7 +169,8 @@ describe("SystemPage", () => {
expect(screen.getByText("Schlüssel umstellen")).toBeInTheDocument();
expect(screen.getByText("Erfolgreich")).toBeInTheDocument();
expect(screen.getByText("Fehlgeschlagen")).toBeInTheDocument();
- expect(screen.getAllByText("Noch nie gelaufen")).toHaveLength(3);
+ expect(screen.getByText("Sprachnotizen abschreiben")).toBeInTheDocument();
+ expect(screen.getAllByText("Noch nie gelaufen")).toHaveLength(4);
});
it("shows the queue counts without a warning while the queue keeps up", async () => {
diff --git a/ApiCore-Frontend/src/app/(app)/settings/system/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/system/page.tsx
index 17bd956..58efc29 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/system/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/system/page.tsx
@@ -30,6 +30,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/page-header";
import { QueryState } from "@/components/query-state";
import { TelegramCard } from "./telegram-card";
+import { VoiceCard } from "./voice-card";
const SYSTEM_QUERY_KEY = ["system"] as const;
const OVERVIEW_QUERY_KEY = [...SYSTEM_QUERY_KEY, "overview"] as const;
@@ -305,6 +306,7 @@ export default function SystemPage() {
{/* The deliveries endpoint needs system:manage; a reader would only get a 403. */}
{canManage ? : null}
+ {canManage ? : null}
>
)}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/system/voice-card.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/system/voice-card.spec.tsx
new file mode 100644
index 0000000..7e450c8
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/settings/system/voice-card.spec.tsx
@@ -0,0 +1,105 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { VoiceCard } from "./voice-card";
+import { fetchVoiceConfig, fetchVoiceStats, type VoiceCaptureStats } from "@/lib/voice/api";
+
+vi.mock("@/lib/voice/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchVoiceStats: vi.fn(),
+ fetchVoiceConfig: vi.fn(),
+}));
+
+const stats: VoiceCaptureStats = {
+ byStatus: { QUEUED: 2, READY: 5, FAILED: 3 },
+ byError: { TRANSCRIBE_FAILED: 2, NO_SPEECH: 1 },
+ tokens: { input: 0, output: 0 },
+ byModel: {},
+};
+
+function renderCard() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+}
+
+describe("VoiceCard", () => {
+ beforeEach(() => {
+ vi.mocked(fetchVoiceStats).mockResolvedValue(stats);
+ vi.mocked(fetchVoiceConfig).mockResolvedValue({
+ recordingEnabled: true,
+ extractionEnabled: true,
+ model: "claude-sonnet-5-5",
+ retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
+ });
+ });
+ afterEach(() => vi.clearAllMocks());
+
+ it("the system card shows counts without user names", async () => {
+ renderCard();
+
+ expect(await screen.findByText("Sprachnotizen")).toBeInTheDocument();
+ expect(await screen.findByText("Fertig – prüfen")).toBeInTheDocument();
+ const text = document.body.textContent ?? "";
+ expect(text).toContain("Fertig – prüfen5");
+ expect(text).toContain("Fehlgeschlagen3");
+ expect(text).toContain("TRANSCRIBE_FAILED");
+ expect(text).toContain("Die Aufnahme konnte nicht abgeschrieben werden.");
+ expect(Object.keys(stats)).not.toContain("users");
+ });
+
+ it("shows the cost as 0 cleanly while no tokens were used", async () => {
+ renderCard();
+
+ expect(await screen.findByText(/0,00\s*\$/)).toBeInTheDocument();
+ expect(screen.getByText(/claude-sonnet-5-5/)).toBeInTheDocument();
+ });
+
+ it("multiplies the tokens by the price of the configured model", async () => {
+ vi.mocked(fetchVoiceStats).mockResolvedValue({
+ ...stats,
+ tokens: { input: 1_000_000, output: 500_000 },
+ });
+ renderCard();
+
+ expect(await screen.findByText(/7,00\s*\$/)).toBeInTheDocument();
+ });
+
+ it("says so when the model has no price instead of guessing", async () => {
+ vi.mocked(fetchVoiceConfig).mockResolvedValue({
+ recordingEnabled: true,
+ extractionEnabled: true,
+ model: "claude-other",
+ retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
+ });
+ vi.mocked(fetchVoiceStats).mockResolvedValue({ ...stats, tokens: { input: 5, output: 5 } });
+ renderCard();
+
+ expect(await screen.findByText(/kein Preis hinterlegt/i)).toBeInTheDocument();
+ });
+
+ it("notes when other models than the configured one were used", async () => {
+ vi.mocked(fetchVoiceStats).mockResolvedValue({
+ ...stats,
+ byModel: { "claude-sonnet-5-5": 4, "claude-haiku-5-5": 1 },
+ });
+ renderCard();
+
+ expect(await screen.findByText(/Schätzung nur für claude-sonnet-5-5/)).toBeInTheDocument();
+ });
+
+ it("shows an empty state without notes", async () => {
+ vi.mocked(fetchVoiceStats).mockResolvedValue({
+ byStatus: {},
+ byError: {},
+ tokens: { input: 0, output: 0 },
+ byModel: {},
+ });
+ renderCard();
+
+ expect(await screen.findByText(/Keine Sprachnotizen/)).toBeInTheDocument();
+ });
+});
diff --git a/ApiCore-Frontend/src/app/(app)/settings/system/voice-card.tsx b/ApiCore-Frontend/src/app/(app)/settings/system/voice-card.tsx
new file mode 100644
index 0000000..6eb12b3
--- /dev/null
+++ b/ApiCore-Frontend/src/app/(app)/settings/system/voice-card.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { QueryState } from "@/components/query-state";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ fetchVoiceConfig,
+ fetchVoiceStats,
+ voiceKeys,
+ type VoiceCaptureStats,
+} from "@/lib/voice/api";
+import { estimateVoiceCostUsd } from "@/lib/voice/prices";
+import { VOICE_ERROR_TEXTS_DE } from "@/lib/voice/status-texts";
+
+/** Words for every server status (the stats count all of them, also the finished ones). */
+const STATUS_LABELS_DE: Readonly> = {
+ QUEUED: "Wartet auf Abschrift",
+ TRANSCRIBING: "Wird abgeschrieben",
+ TRANSCRIBED: "Abgeschrieben",
+ EXTRACTING: "Wird ausgewertet",
+ READY: "Fertig – prüfen",
+ FAILED: "Fehlgeschlagen",
+ CONFIRMED: "Übernommen",
+ DISCARDED: "Verworfen",
+ EXPIRED: "Abgelaufen",
+};
+
+const STATUS_ORDER = Object.keys(STATUS_LABELS_DE);
+
+const usd = new Intl.NumberFormat("de-DE", { style: "currency", currency: "USD" });
+const integer = new Intl.NumberFormat("de-DE");
+
+function sortedEntries(counts: Record, order?: string[]): [string, number][] {
+ const entries = Object.entries(counts);
+ if (!order) {
+ return entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
+ }
+ return entries.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]));
+}
+
+function CountList({ entries, label }: { entries: [string, number][]; label: (key: string) => string }) {
+ return (
+
+ {entries.map(([key, count]) => (
+
+ {label(key)}
+ {count}
+
+ ))}
+
+ );
+}
+
+function otherModelUsed(stats: VoiceCaptureStats, model: string | undefined): boolean {
+ return Object.keys(stats.byModel).some((used) => used !== model);
+}
+
+function costLine(stats: VoiceCaptureStats, model: string | undefined): string {
+ if (!model) {
+ return "Das Modell ist nicht bekannt.";
+ }
+ const cost = estimateVoiceCostUsd(model, stats.tokens);
+ if (cost === null) {
+ return `Für das Modell ${model} ist kein Preis hinterlegt.`;
+ }
+ return `${usd.format(cost)} (Modell ${model})`;
+}
+
+/**
+ * Counters per status and error code of the last 7 days, and the estimated
+ * evaluation cost. Only counts: no names, no content. Needs system:manage
+ * (the stats endpoint); the caller shows it only then.
+ */
+export function VoiceCard() {
+ const statsQuery = useQuery({ queryKey: voiceKeys.stats, queryFn: fetchVoiceStats });
+ const configQuery = useQuery({ queryKey: voiceKeys.config, queryFn: fetchVoiceConfig });
+
+ return (
+
+
+ Sprachnotizen
+
+
+
+ {(stats) => {
+ const statuses = sortedEntries(stats.byStatus, STATUS_ORDER);
+ const errors = sortedEntries(stats.byError);
+ return (
+ <>
+ Die letzten 7 Tage.
+ {statuses.length === 0 ? (
+ Keine Sprachnotizen.
+ ) : (
+ STATUS_LABELS_DE[key] ?? key} />
+ )}
+ {errors.length > 0 ? (
+
+
Fehlercodes
+ `${key}: ${VOICE_ERROR_TEXTS_DE[key] ?? "Unbekannter Fehler."}`}
+ />
+
+ ) : null}
+
+
Geschätzte Kosten der Auswertung
+
+ {integer.format(stats.tokens.input)} Token Eingabe,{" "}
+ {integer.format(stats.tokens.output)} Token Ausgabe
+
+
{costLine(stats, configQuery.data?.model)}
+ {otherModelUsed(stats, configQuery.data?.model) ? (
+
+ Schätzung nur für {configQuery.data?.model}: in diesen Tagen lief auch ein
+ anderes Modell.
+
+ ) : null}
+
+ Preis je Token aus der Preisliste des Modells, nicht aus der Rechnung.
+
+
+ >
+ );
+ }}
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/app/(app)/settings/voice/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/voice/page.spec.tsx
index 30c1736..157d123 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/voice/page.spec.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/voice/page.spec.tsx
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import axe from "axe-core";
@@ -7,12 +7,48 @@ 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";
+import {
+ discardAllVoiceCaptures,
+ fetchVoiceCaptures,
+ fetchVoiceConfig,
+ type VoiceCaptureListItem,
+} from "@/lib/voice/api";
+import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
+import { toast } from "sonner";
+import { discardAllPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
let permissions = new Set();
vi.mock("@/lib/auth/auth-context", () => ({
- useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
+ useAuth: () => ({
+ user: { id: "u-1" },
+ hasPermission: (permission: string) => permissions.has(permission),
+ }),
}));
vi.mock("@/lib/site-settings/api", () => ({ fetchSiteSettings: vi.fn() }));
+vi.mock("@/lib/voice/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ fetchVoiceConfig: vi.fn(),
+ fetchVoiceCaptures: vi.fn(),
+ discardAllVoiceCaptures: vi.fn(),
+}));
+vi.mock("@/lib/voice/pending-voice", () => ({
+ listPendingVoice: vi.fn(),
+ discardAllPendingVoice: vi.fn(),
+}));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+function listItem(id: string, status: VoiceCaptureListItem["status"]): VoiceCaptureListItem {
+ return {
+ id,
+ colonyId: "c-1",
+ colonyName: "V1",
+ status,
+ source: "AUDIO",
+ createdAt: "2026-10-16T08:00:00.000Z",
+ lastErrorCode: null,
+ contentExpiresAt: "2026-10-23T08:00:00.000Z",
+ };
+}
function settings(overrides: Partial = {}): SiteSettings {
return {
@@ -33,11 +69,12 @@ function settings(overrides: Partial = {}): SiteSettings {
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- return render(
+ const result = render(
,
);
+ return { ...result, client };
}
describe("VoiceSettingsPage", () => {
@@ -45,6 +82,106 @@ describe("VoiceSettingsPage", () => {
window.localStorage.clear();
permissions = new Set();
vi.mocked(fetchSiteSettings).mockReset().mockResolvedValue(settings());
+ vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue({
+ recordingEnabled: true,
+ extractionEnabled: true,
+ model: "claude-sonnet-5-5",
+ retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
+ });
+ vi.mocked(fetchVoiceCaptures)
+ .mockReset()
+ .mockResolvedValue([listItem("a", "READY"), listItem("b", "FAILED"), listItem("c", "CONFIRMED")]);
+ vi.mocked(listPendingVoice).mockReset().mockResolvedValue([{ id: "n1" }] as never);
+ vi.mocked(discardAllVoiceCaptures).mockReset().mockResolvedValue({ discarded: 2 });
+ vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(1);
+ });
+
+ it("shows the model id and the retention numbers from the server", async () => {
+ renderPage();
+
+ expect(await screen.findByText("claude-sonnet-5-5")).toBeInTheDocument();
+ expect(screen.getByText(/Aufnahme: 24 Stunden/)).toBeInTheDocument();
+ expect(screen.getByText(/Text und Auswertung: 7 Tage/)).toBeInTheDocument();
+ expect(screen.getByText(/Eintrag ohne Inhalt: 90 Tage/)).toBeInTheDocument();
+ });
+
+ it("says the evaluation is off and names no model while extraction is disabled", async () => {
+ vi.mocked(fetchVoiceConfig).mockResolvedValue({
+ recordingEnabled: true,
+ extractionEnabled: false,
+ model: "claude-sonnet-5-5",
+ retention: { audioHours: 24, contentDays: 7, rowDays: 90 },
+ });
+ renderPage();
+
+ expect(await screen.findByText(/Die Auswertung ist ausgeschaltet/)).toBeInTheDocument();
+ expect(screen.getByText(/nur das Abschreiben läuft im eigenen Cluster/)).toBeInTheDocument();
+ expect(screen.queryByText("claude-sonnet-5-5")).not.toBeInTheDocument();
+ expect(screen.getByText(/Aufnahme: 24 Stunden/)).toBeInTheDocument();
+ });
+
+ it("says so when the server values cannot be loaded", async () => {
+ vi.mocked(fetchVoiceConfig).mockRejectedValue(new Error("offline"));
+ renderPage();
+
+ expect(await screen.findByText(/Die Werte konnten nicht geladen werden/)).toBeInTheDocument();
+ });
+
+ it("delete all asks with the count and empties server and device", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ const trigger = await screen.findByRole("button", { name: "Alle meine Sprachnotizen löschen" });
+ await waitFor(() => expect(trigger).toBeEnabled());
+ await user.click(trigger);
+
+ const dialog = await screen.findByRole("alertdialog");
+ // 2 open on the server (READY, FAILED; CONFIRMED is done) + 1 on this device
+ expect(dialog).toHaveTextContent("3 Sprachnotizen");
+ expect(discardAllVoiceCaptures).not.toHaveBeenCalled();
+
+ await user.click(within(dialog).getByRole("button", { name: "Alle löschen" }));
+
+ await waitFor(() => expect(discardAllVoiceCaptures).toHaveBeenCalledTimes(1));
+ expect(discardAllPendingVoice).toHaveBeenCalledWith("u-1");
+ });
+
+ it("still empties this device when the server cannot be reached", async () => {
+ vi.mocked(discardAllVoiceCaptures).mockRejectedValue(new Error("offline"));
+ const user = userEvent.setup();
+ renderPage();
+
+ const trigger = await screen.findByRole("button", { name: "Alle meine Sprachnotizen löschen" });
+ await waitFor(() => expect(trigger).toBeEnabled());
+ await user.click(trigger);
+ await user.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Alle löschen" }));
+
+ await waitFor(() => expect(discardAllPendingVoice).toHaveBeenCalledWith("u-1"));
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Gerät[\s\S]*gelöscht[\s\S]*Server/)),
+ );
+ });
+
+ it("refreshes the offline queue after delete all", async () => {
+ const user = userEvent.setup();
+ const { client } = renderPage();
+ const spy = vi.spyOn(client, "invalidateQueries");
+
+ const trigger = await screen.findByRole("button", { name: "Alle meine Sprachnotizen löschen" });
+ await waitFor(() => expect(trigger).toBeEnabled());
+ await user.click(trigger);
+ await user.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Alle löschen" }));
+
+ await waitFor(() => expect(spy).toHaveBeenCalledWith({ queryKey: OFFLINE_QUEUE_QUERY_KEY }));
+ });
+
+ it("disables delete all when there is nothing to delete", async () => {
+ vi.mocked(fetchVoiceCaptures).mockResolvedValue([listItem("c", "CONFIRMED")]);
+ vi.mocked(listPendingVoice).mockResolvedValue([]);
+ renderPage();
+
+ expect(await screen.findByText(/Keine offenen Sprachnotizen/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Alle meine Sprachnotizen löschen" })).toBeDisabled();
});
it("the cloud switch is off by default and stores the consent", async () => {
diff --git a/ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx
index b18c6ad..cdc6817 100644
--- a/ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx
@@ -1,8 +1,10 @@
"use client";
import { useSyncExternalStore } from "react";
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShieldCheck } from "lucide-react";
+import { toast } from "sonner";
+import { ConfirmDialog } from "@/components/confirm-dialog";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchSiteSettings } from "@/lib/site-settings/api";
import {
@@ -10,6 +12,15 @@ import {
setCloudRecognitionConsent,
subscribeCloudRecognitionConsent,
} from "@/lib/voice/consent";
+import {
+ discardAllVoiceCaptures,
+ fetchVoiceCaptures,
+ fetchVoiceConfig,
+ voiceKeys,
+} from "@/lib/voice/api";
+import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
+import { discardAllPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
+import { isListedVoiceStatus } from "@/lib/voice/status-texts";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -61,6 +72,128 @@ function CloudConsentCard() {
);
}
+function ConfigCard() {
+ const { data, isError } = useQuery({ queryKey: voiceKeys.config, queryFn: fetchVoiceConfig });
+
+ return (
+
+
+ Modell und Fristen
+ {data ? (
+ <>
+ {data.extractionEnabled ? (
+
+ Die Auswertung läuft mit dem Modell{" "}
+ {data.model}.
+
+ ) : (
+
+ Die Auswertung ist ausgeschaltet. Es wird nichts an einen KI-Dienst gesendet; nur
+ das Abschreiben läuft im eigenen Cluster.
+
+ )}
+
+ Aufnahme: {data.retention.audioHours} Stunden
+ Text und Auswertung: {data.retention.contentDays} Tage
+ Eintrag ohne Inhalt: {data.retention.rowDays} Tage
+
+ >
+ ) : (
+
+ {isError
+ ? "Die Werte konnten nicht geladen werden. Es gelten die Fristen oben."
+ : "Werte werden geladen …"}
+
+ )}
+
+
+ );
+}
+
+function failureMessage(serverFailed: boolean, deviceFailed: boolean): string {
+ if (serverFailed && deviceFailed) {
+ return "Es konnte nichts gelöscht werden: weder auf dem Server noch auf diesem Gerät. Bitte später noch einmal versuchen.";
+ }
+ return serverFailed
+ ? "Auf diesem Gerät sind die Sprachnotizen gelöscht, der Server war nicht erreichbar. Bitte später noch einmal versuchen."
+ : "Auf dem Server sind die Sprachnotizen gelöscht, auf diesem Gerät konnten sie nicht gelöscht werden.";
+}
+
+function noteCount(count: number): string {
+ return count === 1 ? "1 Sprachnotiz" : `${count} Sprachnotizen`;
+}
+
+/** Counts what delete-all would remove: open notes on the server plus unsent ones on this device. */
+function DeleteAllCard() {
+ const { user } = useAuth();
+ const userId = user?.id ?? "";
+ const queryClient = useQueryClient();
+ const server = useQuery({
+ queryKey: [...voiceKeys.all, "open-count"],
+ queryFn: async () => {
+ const items = await fetchVoiceCaptures();
+ return items.filter((item) => isListedVoiceStatus(item.status)).length;
+ },
+ });
+ const device = useQuery({
+ queryKey: ["pending-voice", userId, "count"],
+ queryFn: async () => (await listPendingVoice(userId)).length,
+ enabled: userId !== "",
+ });
+ const serverCount = server.data ?? 0;
+ const deviceCount = device.data ?? 0;
+ const total = serverCount + deviceCount;
+ const isLoading = server.isPending || (userId !== "" && device.isPending);
+
+ const discardAll = useMutation({
+ // Both sides always run: a server that cannot be reached must not keep the device's copies.
+ mutationFn: async () => {
+ const [serverResult, deviceResult] = await Promise.allSettled([
+ discardAllVoiceCaptures(),
+ discardAllPendingVoice(userId),
+ ]);
+ return { serverFailed: serverResult.status === "rejected", deviceFailed: deviceResult.status === "rejected" };
+ },
+ onSuccess: ({ serverFailed, deviceFailed }) => {
+ if (serverFailed || deviceFailed) {
+ toast.error(failureMessage(serverFailed, deviceFailed));
+ } else {
+ toast.success("Alle Sprachnotizen gelöscht");
+ }
+ void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
+ return queryClient.invalidateQueries({ queryKey: voiceKeys.all });
+ },
+ onSettled: () => queryClient.invalidateQueries({ queryKey: ["pending-voice"] }),
+ });
+
+ return (
+
+
+ Meine Sprachnotizen löschen
+
+ {isLoading
+ ? "Anzahl wird ermittelt …"
+ : total === 0
+ ? "Keine offenen Sprachnotizen."
+ : `Offen: ${noteCount(serverCount)} auf dem Server, ${noteCount(deviceCount)} auf diesem Gerät.`}
+
+
+ discardAll.mutate()}
+ />
+
+
+
+ );
+}
+
function HolderCard() {
const { hasPermission } = useAuth();
const canRead = hasPermission("site-settings:read");
@@ -109,6 +242,8 @@ export default function VoiceSettingsPage() {
{RECORDING_NOTICE}
+
+
);
diff --git a/ApiCore-Frontend/src/components/offline/pending-queue-sheet.spec.tsx b/ApiCore-Frontend/src/components/offline/pending-queue-sheet.spec.tsx
index 2cfa35a..f682ece 100644
--- a/ApiCore-Frontend/src/components/offline/pending-queue-sheet.spec.tsx
+++ b/ApiCore-Frontend/src/components/offline/pending-queue-sheet.spec.tsx
@@ -7,6 +7,7 @@ import {
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
+ retryInspectionWithoutVoice,
type QueueItem,
type StoredPhoto,
} from "@/lib/offline/db";
@@ -20,6 +21,7 @@ vi.mock("@/lib/offline/db", () => ({
discardQueueItem: vi.fn(),
retryBulkWithout: vi.fn(),
retryInspectionWithoutHiveChange: vi.fn(),
+ retryInspectionWithoutVoice: vi.fn(),
}));
vi.mock("@/lib/voice/pending-voice", () => ({
discardPendingVoice: vi.fn(),
@@ -101,6 +103,7 @@ describe("PendingQueueSheet", () => {
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined);
+ vi.mocked(retryInspectionWithoutVoice).mockReset().mockResolvedValue(undefined);
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
vi.mocked(discardPendingVoice).mockReset().mockResolvedValue(undefined);
vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(2);
@@ -165,6 +168,34 @@ describe("PendingQueueSheet", () => {
);
});
+ it("words a voice note of another colony and sends the inspection without it", async () => {
+ vi.mocked(listQueue).mockResolvedValue([
+ {
+ store: "pending-inspections",
+ id: 21,
+ label: "Durchsicht",
+ queuedAt: "2026-10-14T08:00:00.000Z",
+ canResendWithoutVoice: true,
+ lastError: {
+ status: 409,
+ message: "Die Sprachnotiz gehört zu einem anderen Volk.",
+ details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
+ at: "2026-10-14T09:00:00.000Z",
+ },
+ },
+ ]);
+ renderSheet();
+ const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
+
+ expect(await within(sheet).findByText(/Die Sprachnotiz gehört zu einem anderen Volk/)).toBeInTheDocument();
+ await userEvent.click(within(sheet).getByRole("button", { name: "Ohne Sprachnotiz senden" }));
+
+ await waitFor(() => expect(retryInspectionWithoutVoice).toHaveBeenCalledWith(21));
+ await waitFor(() =>
+ expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
+ );
+ });
+
it("lists a rejected hive change with the conflict text and only the discard action", async () => {
vi.mocked(listQueue).mockResolvedValue([
{
diff --git a/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx b/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx
index f43da7d..6164ea5 100644
--- a/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx
+++ b/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx
@@ -14,6 +14,7 @@ import {
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
+ retryInspectionWithoutVoice,
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
@@ -289,6 +290,24 @@ export function PendingQueueSheet({
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
+ // „Ohne Sprachnotiz senden“: only after the server said the note is another colony's.
+ const resendWithoutVoice = useMutation({
+ mutationFn: async (id: number) => {
+ await retryInspectionWithoutVoice(id);
+ return syncPendingEntries(userId, { afterChange: true });
+ },
+ networkMode: "always",
+ onSuccess: (result) => {
+ if (result.synced > 0) {
+ toast.success(entriesSynced(result.synced));
+ void queryClient.invalidateQueries();
+ return;
+ }
+ void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
+ },
+ onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
+ });
+
const records = items.filter((item) => item.store !== VOICE_STORE);
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
const names = entryNames(records);
@@ -354,6 +373,19 @@ export function PendingQueueSheet({
Ohne Beute-Änderung senden
) : null}
+ {item.canResendWithoutVoice && typeof item.id === "number" ? (
+ resendWithoutVoice.mutate(item.id as number)}
+ >
+
+ Ohne Sprachnotiz senden
+
+ ) : null}
{item.resendWithout && typeof item.id === "number" ? (
= {}): VoiceCaptureDetail {
+ return {
+ id: "cap-1",
+ colonyId: "colony-1",
+ colonyName: "V-01",
+ status: "READY",
+ source: "AUDIO",
+ createdAt: "2026-10-14T08:00:00.000Z",
+ lastErrorCode: null,
+ contentExpiresAt: "2026-10-21T08:00:00.000Z",
+ inspectionDate: "2026-10-14",
+ transcript: "Königin gesehen, vier Brutwaben.",
+ suggestions: [suggestion("queenSeen", true), suggestion("broodFrames", 4)],
+ unclear: [],
+ dropped: 0,
+ ...extra,
+ };
+}
+
+function renderBox(
+ extra: Partial = {},
+ handlers: { openCount?: number; onTakeOverAll?: () => void; onDiscard?: () => void } = {},
+) {
+ const onTakeOverAll = handlers.onTakeOverAll ?? vi.fn();
+ const onDiscard = handlers.onDiscard ?? vi.fn();
+ const view = render(
+ ,
+ );
+ return { ...view, onTakeOverAll, onDiscard };
+}
+
+describe("VoiceCaptureBox", () => {
+ it("names the day and the number of suggestions", () => {
+ renderBox();
+
+ expect(
+ screen.getByRole("heading", { name: "Sprachnotiz vom 14.10., 2 Vorschläge" }),
+ ).toBeInTheDocument();
+ });
+
+ it("says Vorschlag for one", () => {
+ renderBox({ suggestions: [suggestion("queenSeen", true)] }, { openCount: 1 });
+
+ expect(
+ screen.getByRole("heading", { name: "Sprachnotiz vom 14.10., 1 Vorschlag" }),
+ ).toBeInTheDocument();
+ });
+
+ it("Alle übernehmen asks first and names the count", async () => {
+ const user = userEvent.setup();
+ const { onTakeOverAll } = renderBox({}, { openCount: 12 });
+
+ await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
+
+ expect(onTakeOverAll).not.toHaveBeenCalled();
+ const dialog = screen.getByRole("alertdialog");
+ expect(
+ within(dialog).getByText("12 Felder aus der Sprachnotiz übernehmen?"),
+ ).toBeInTheDocument();
+ await user.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
+ expect(onTakeOverAll).toHaveBeenCalledOnce();
+ });
+
+ it("Alle übernehmen can be cancelled and is gone when nothing is left to take", async () => {
+ const user = userEvent.setup();
+ const { onTakeOverAll, rerender } = renderBox();
+
+ await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
+ await user.click(
+ within(screen.getByRole("alertdialog")).getByRole("button", { name: "Abbrechen" }),
+ );
+ expect(onTakeOverAll).not.toHaveBeenCalled();
+
+ rerender(
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
+ });
+
+ it("Sprachnotiz verwerfen asks first", async () => {
+ const user = userEvent.setup();
+ const { onDiscard } = renderBox();
+
+ await user.click(screen.getByRole("button", { name: "Sprachnotiz verwerfen" }));
+ expect(onDiscard).not.toHaveBeenCalled();
+ await user.click(
+ within(screen.getByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
+ );
+ expect(onDiscard).toHaveBeenCalledOnce();
+ });
+
+ it("offers what was said, as text", () => {
+ renderBox({ transcript: "Königin gesehen" });
+
+ const details = screen.getByText("Gesagt wurde:").closest("details");
+ expect(details).not.toBeNull();
+ expect(details).not.toHaveAttribute("open");
+ expect(within(details as HTMLElement).getByText("Königin gesehen")).toBeInTheDocument();
+ expect(details?.querySelector("b")).toBeNull();
+ });
+
+ it("lists what could not be assigned without a button", () => {
+ renderBox({ unclear: ["Etwas zur Futtermenge, Menge nicht genannt"] });
+
+ const list = screen.getByRole("list", { name: "Das konnte ich nicht zuordnen" });
+ expect(
+ within(list).getByText("Etwas zur Futtermenge, Menge nicht genannt"),
+ ).toBeInTheDocument();
+ expect(within(list).queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("says so when nothing clear was recognised and points to the text", () => {
+ renderBox({ suggestions: [], dropped: 3 }, { openCount: 0 });
+
+ expect(
+ screen.getByText("Nichts Eindeutiges erkannt. Der Text steht darunter."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
+ expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["QUEUED", "Wird noch abgeschrieben"],
+ ["TRANSCRIBING", "Wird noch abgeschrieben"],
+ ["TRANSCRIBED", "Wird noch ausgewertet"],
+ ["EXTRACTING", "Wird noch ausgewertet"],
+ ] as const)("a %s note says it is still being worked on", (status, text) => {
+ renderBox({ status, suggestions: null, transcript: null });
+
+ expect(screen.getByText(text)).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
+ });
+
+ it("a transcribed note whose evaluation is off shows the text open for typing it in", () => {
+ renderBox(
+ { status: "TRANSCRIBED", lastErrorCode: "EXTRACT_DISABLED", suggestions: null },
+ { openCount: 0 },
+ );
+
+ expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
+ expect(screen.getByText(/Die Auswertung ist ausgeschaltet/)).toBeInTheDocument();
+ expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
+ expect(screen.getByText("Königin gesehen, vier Brutwaben.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Sprachnotiz verwerfen" })).toBeInTheDocument();
+ });
+
+ it("a transcribed note shows the text-only state when the evaluation is switched off", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
+ expect(screen.queryByText("Wird noch ausgewertet")).not.toBeInTheDocument();
+ expect(screen.getByText("Gesagt wurde:").closest("details")).toHaveAttribute("open");
+ });
+
+ it("focuses the box after Alle übernehmen, whose button is gone then", async () => {
+ const user = userEvent.setup();
+ renderBox({}, { openCount: 2 });
+
+ await user.click(screen.getByRole("button", { name: "Alle übernehmen" }));
+ await user.click(
+ within(screen.getByRole("alertdialog")).getByRole("button", { name: "Übernehmen" }),
+ );
+
+ await vi.waitFor(() =>
+ expect(screen.getByRole("region", { name: /^Sprachnotiz vom/ })).toHaveFocus(),
+ );
+ });
+
+ it("a failed note with text shows the text; one without shows only the reason", () => {
+ const { unmount } = renderBox(
+ { status: "FAILED", lastErrorCode: "EXTRACT_FAILED", suggestions: null },
+ { openCount: 0 },
+ );
+ expect(screen.getByText("Auswertung gerade nicht möglich")).toBeInTheDocument();
+ expect(screen.getByText("Königin gesehen, vier Brutwaben.")).toBeInTheDocument();
+ unmount();
+
+ renderBox(
+ { status: "FAILED", lastErrorCode: "NO_SPEECH", suggestions: null, transcript: null },
+ { openCount: 0 },
+ );
+ expect(screen.getByText(/Es war nichts zu verstehen/)).toBeInTheDocument();
+ expect(screen.queryByText("Gesagt wurde:")).not.toBeInTheDocument();
+ });
+
+ it.each(["CONFIRMED", "DISCARDED", "EXPIRED"] as const)(
+ "a %s note says it is gone and can still be left out",
+ (status) => {
+ renderBox({ status, suggestions: null, transcript: null }, { openCount: 0 });
+
+ expect(screen.getByText(/Die Sprachnotiz gibt es nicht mehr/)).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Sprachnotiz verwerfen" }),
+ ).not.toBeInTheDocument();
+ },
+ );
+
+ it("axe finds nothing", async () => {
+ const { container } = renderBox({ unclear: ["Etwas unklar"] });
+
+ const result = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
+ expect(result.violations).toEqual([]);
+ });
+});
diff --git a/ApiCore-Frontend/src/components/voice/voice-capture-box.tsx b/ApiCore-Frontend/src/components/voice/voice-capture-box.tsx
new file mode 100644
index 0000000..27efc1b
--- /dev/null
+++ b/ApiCore-Frontend/src/components/voice/voice-capture-box.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import type { VoiceCaptureDetail } from "@/lib/voice/api";
+import { voiceErrorText } from "@/lib/voice/status-texts";
+import { VoiceConfirmDialog } from "./voice-confirm-dialog";
+
+export const VOICE_CAPTURE_BOX_ID = "voice-capture-box";
+
+interface VoiceCaptureBoxProps {
+ capture: VoiceCaptureDetail;
+ /** Suggestions still on offer (not taken, not ignored): what „Alle übernehmen“ would take. */
+ openCount: number;
+ onTakeOverAll: () => void;
+ onDiscard: () => void;
+ discarding?: boolean;
+ /** false: the operator switched the evaluation off; a transcribed note is then text only. */
+ extractionEnabled?: boolean;
+}
+
+type Dialog = "take-all" | "discard" | null;
+
+const GONE_STATUSES: readonly string[] = ["CONFIRMED", "DISCARDED", "EXPIRED"];
+const TRANSCRIBING_STATUSES: readonly string[] = ["QUEUED", "TRANSCRIBING"];
+const EVALUATING_STATUSES: readonly string[] = ["TRANSCRIBED", "EXTRACTING"];
+/** Codes after which the text exists, but no suggestions will come. */
+const TEXT_ONLY_CODES: readonly string[] = ["EXTRACT_DISABLED", "DAILY_LIMIT"];
+
+/** "2026-10-14" -> "14.10." */
+function dayText(isoDay: string): string {
+ const [, month = "", day = ""] = isoDay.slice(0, 10).split("-");
+ return `${day}.${month}.`;
+}
+
+function suggestionCount(count: number): string {
+ return count === 1 ? "1 Vorschlag" : `${count} Vorschläge`;
+}
+
+/** The one line that says where this note stands, or null when its suggestions speak. */
+function stateLine(
+ capture: VoiceCaptureDetail,
+ extractionEnabled: boolean,
+): { text: string; detail?: string } | null {
+ const { status, lastErrorCode } = capture;
+ if (status === "TRANSCRIBED" && !extractionEnabled && !lastErrorCode) {
+ return { text: "Auswertung gerade nicht möglich", detail: voiceErrorText("EXTRACT_DISABLED") };
+ }
+ if (GONE_STATUSES.includes(status)) {
+ return { text: "Die Sprachnotiz gibt es nicht mehr. Du kannst die Durchsicht trotzdem speichern." };
+ }
+ if (status === "FAILED" && capture.transcript === null) {
+ return { text: voiceErrorText(lastErrorCode) };
+ }
+ if (status === "FAILED" || (status === "TRANSCRIBED" && lastErrorCode && TEXT_ONLY_CODES.includes(lastErrorCode))) {
+ return { text: "Auswertung gerade nicht möglich", detail: voiceErrorText(lastErrorCode) };
+ }
+ if (TRANSCRIBING_STATUSES.includes(status)) {
+ return { text: "Wird noch abgeschrieben" };
+ }
+ if (EVALUATING_STATUSES.includes(status)) {
+ return { text: "Wird noch ausgewertet" };
+ }
+ return null;
+}
+
+/**
+ * The voice note above the inspection form: how many suggestions it holds,
+ * what was said, what could not be assigned, and the two box-wide choices -
+ * all at once (asked first) or discard (asked first). It places nothing in the
+ * form itself. For a note without suggestions it is the way to read the text.
+ */
+export function VoiceCaptureBox({
+ capture,
+ openCount,
+ onTakeOverAll,
+ onDiscard,
+ discarding = false,
+ extractionEnabled = true,
+}: VoiceCaptureBoxProps) {
+ const [dialog, setDialog] = useState(null);
+ const [copyNote, setCopyNote] = useState("");
+ const line = stateLine(capture, extractionEnabled);
+ const isGone = GONE_STATUSES.includes(capture.status);
+ const isReady = capture.status === "READY";
+ const suggestions = capture.suggestions ?? [];
+ const nothingFound = isReady && suggestions.length === 0;
+ const textOnly =
+ nothingFound ||
+ (line !== null && !isGone && capture.transcript !== null && line.detail !== undefined);
+ const heading = isReady
+ ? `Sprachnotiz vom ${dayText(capture.inspectionDate)}, ${suggestionCount(suggestions.length)}`
+ : `Sprachnotiz vom ${dayText(capture.inspectionDate)}`;
+
+ async function copyText() {
+ try {
+ await navigator.clipboard.writeText(capture.transcript ?? "");
+ setCopyNote("Text kopiert");
+ } catch {
+ setCopyNote("Kopieren nicht möglich – markiere den Text und kopiere ihn.");
+ }
+ }
+
+ function close() {
+ setDialog(null);
+ }
+
+ /** The button that opened the dialog may be gone by now: the box takes the focus. */
+ function focusBox() {
+ window.setTimeout(() => document.getElementById(VOICE_CAPTURE_BOX_ID)?.focus(), 0);
+ }
+
+ return (
+
+
+ {heading}
+
+
+ {line ? (
+
+
{line.text}
+ {line.detail ?
{line.detail}
: null}
+
+ ) : null}
+ {isReady && suggestions.length === 0 ? (
+ Nichts Eindeutiges erkannt. Der Text steht darunter.
+ ) : null}
+
+ {capture.transcript ? (
+
+
+ Gesagt wurde:
+
+ {capture.transcript}
+
+
+ Text kopieren
+
+
+ {copyNote}
+
+
+
+ ) : null}
+
+ {isReady && capture.unclear.length > 0 ? (
+
+
+ Das konnte ich nicht zuordnen
+
+
+ {capture.unclear.map((sentence) => (
+ {sentence}
+ ))}
+
+
+ ) : null}
+
+ {!isGone ? (
+
+ {isReady && openCount > 0 ? (
+ setDialog("take-all")}
+ >
+ Alle übernehmen
+
+ ) : null}
+ setDialog("discard")}
+ >
+ Sprachnotiz verwerfen
+
+
+ ) : null}
+
+ {
+ close();
+ onTakeOverAll();
+ focusBox();
+ }}
+ />
+ {
+ close();
+ onDiscard();
+ }}
+ />
+
+ );
+}
diff --git a/ApiCore-Frontend/src/components/voice/voice-confirm-dialog.tsx b/ApiCore-Frontend/src/components/voice/voice-confirm-dialog.tsx
index 96d5c0e..0ddff8b 100644
--- a/ApiCore-Frontend/src/components/voice/voice-confirm-dialog.tsx
+++ b/ApiCore-Frontend/src/components/voice/voice-confirm-dialog.tsx
@@ -17,6 +17,8 @@ interface VoiceConfirmDialogProps {
/** Leaves the note as it is. */
cancelLabel: string;
confirmLabel: string;
+ /** destructive (red) when something is thrown away; default when a choice is only confirmed. */
+ confirmVariant?: "destructive" | "default";
onCancel(): void;
onConfirm(): void;
}
@@ -31,6 +33,7 @@ export function VoiceConfirmDialog({
description,
cancelLabel,
confirmLabel,
+ confirmVariant = "destructive",
onCancel,
onConfirm,
}: VoiceConfirmDialogProps) {
@@ -47,7 +50,7 @@ export function VoiceConfirmDialog({
= {}): VoiceSuggestionProps {
+ return {
+ fieldLabel: "Königin gesehen",
+ valueText: "Königin gesehen: Ja",
+ quote: "Königin hab ich gesehen",
+ onTakeOver: vi.fn(),
+ onEdit: vi.fn(),
+ ...extra,
+ };
+}
+
+/** A parent that behaves like the form: taking over flips the row to „taken“. */
+function Harness({ onEdit = vi.fn() }: { onEdit?: () => void }) {
+ const [status, setStatus] = useState<"open" | "taken" | "ignored">("open");
+ return (
+ <>
+
+ setStatus("taken")}
+ onUndo={() => setStatus("open")}
+ onIgnore={() => setStatus("ignored")}
+ onRestore={() => setStatus("open")}
+ />
+ >
+ );
+}
+
+describe("VoiceSuggestion", () => {
+ it("shows the value in words and the quote visibly marked as spoken", () => {
+ render( );
+
+ expect(screen.getByText("Königin gesehen: Ja")).toBeInTheDocument();
+ const quote = screen.getByText(/Königin hab ich gesehen/);
+ expect(quote.closest("blockquote")).not.toBeNull();
+ expect(quote).toHaveTextContent("„Königin hab ich gesehen“");
+ expect(screen.getByText("Aus Sprache")).toBeInTheDocument();
+ expect(screen.getByText("Gesagt:")).toBeInTheDocument();
+ });
+
+ it("shows chips as words", () => {
+ render( );
+
+ expect(screen.getByText("Korrigiert")).toBeInTheDocument();
+ expect(screen.getByText("Zuvor genannt: Nein")).toBeInTheDocument();
+ });
+
+ it("names the field on each button so a screen reader hears what it acts on", () => {
+ render( );
+
+ expect(screen.getByRole("button", { name: /^Übernehmen.*Königin gesehen/ })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^Ändern.*Königin gesehen/ })).toBeInTheDocument();
+ });
+
+ it("Übernehmen sets the value, says so in a status region and keeps the focus where it was", async () => {
+ const user = userEvent.setup();
+ render( );
+ const take = screen.getByRole("button", { name: /^Übernehmen/ });
+
+ await user.tab(); // the field
+ await user.tab(); // Übernehmen
+ expect(take).toHaveFocus();
+ await user.keyboard("{Enter}");
+
+ expect(screen.getByRole("status")).toHaveTextContent("Übernommen");
+ // The same element carries on as „Rückgängig“: the focus never falls to the page.
+ expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
+ });
+
+ it("Rückgängig takes the value back and offers it again", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: /^Übernehmen/ }));
+ await user.click(screen.getByRole("button", { name: /^Rückgängig/ }));
+
+ expect(screen.getByRole("button", { name: /^Übernehmen/ })).toHaveFocus();
+ expect(screen.getByRole("status")).toBeEmptyDOMElement();
+ });
+
+ it("Ändern calls onEdit", async () => {
+ const onEdit = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole("button", { name: /^Ändern/ }));
+
+ expect(onEdit).toHaveBeenCalledOnce();
+ });
+
+ it("Ignorieren hides the choices behind one button and keeps the focus on it", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: /^Ignorieren/ }));
+
+ expect(screen.getByRole("status")).toHaveTextContent("Ignoriert");
+ expect(screen.queryByRole("button", { name: /^Ändern/ })).not.toBeInTheDocument();
+ const restore = screen.getByRole("button", { name: /^Wieder anzeigen/ });
+ expect(restore).toHaveFocus();
+ await user.click(restore);
+ expect(screen.getByRole("button", { name: /^Übernehmen/ })).toBeInTheDocument();
+ });
+
+ it("works with the keyboard only", async () => {
+ const user = userEvent.setup();
+ const onEdit = vi.fn();
+ render( );
+
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(screen.getByRole("button", { name: /^Ändern/ })).toHaveFocus();
+ await user.keyboard(" ");
+ expect(onEdit).toHaveBeenCalledOnce();
+ });
+
+ it("suggestion text is rendered as text, never as HTML", () => {
+ const { container } = render(
+ ",
+ quote: "",
+ chips: ["fett "],
+ })}
+ />,
+ );
+
+ expect(container.querySelector("img, script, b")).toBeNull();
+ expect(screen.getByText(/