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} +
+ +
{INSPECTION_OBSERVATIONS.map((observation) => ( - set(observation.key, answer)} - previous={previousFor(observation.key)} - /> +
+ set(observation.key, answer)} + previous={previousFor(observation.key)} + /> + +
))}
{noQueenSigns ? ( @@ -653,10 +717,11 @@ function InspectionCaptureForm({ describedBy={describedBy} value={values.broodPattern} onChange={(rating) => set("broodPattern", rating)} - lowLabel="lückenhaft" - highLabel="geschlossen" + {...INSPECTION_SCALE_LABELS.broodPattern} + direction={INSPECTION_SCALE_DIRECTIONS.broodPattern} /> {hintFor("broodPattern")} + )} @@ -673,6 +738,7 @@ function InspectionCaptureForm({ describedBy={describedBy} /> {hintFor("broodFrames")} + {broodFromHive !== null && values.broodFrames !== String(broodFromHive) && !(broodFromHive === 0 && values.broodFrames === "") ? ( @@ -727,6 +793,7 @@ function InspectionCaptureForm({ describedBy={describedBy} /> {hintFor("colonyStrength")} + )} @@ -738,10 +805,11 @@ function InspectionCaptureForm({ labelledBy={labelId} value={values.gentleness} onChange={(rating) => set("gentleness", rating)} - lowLabel="stechlustig" - highLabel="sehr sanft" + {...INSPECTION_SCALE_LABELS.gentleness} + direction={INSPECTION_SCALE_DIRECTIONS.gentleness} /> {hintFor("gentleness")} + )} @@ -753,10 +821,11 @@ function InspectionCaptureForm({ labelledBy={labelId} value={values.combBehavior} onChange={(rating) => set("combBehavior", rating)} - lowLabel="läuft weg" - highLabel="sitzt ruhig" + {...INSPECTION_SCALE_LABELS.combBehavior} + direction={INSPECTION_SCALE_DIRECTIONS.combBehavior} /> {hintFor("combBehavior")} + )} @@ -768,11 +837,11 @@ function InspectionCaptureForm({ labelledBy={labelId} value={values.swarmMood} onChange={(rating) => set("swarmMood", rating)} - direction="lower-is-better" - lowLabel="ruhig" - highLabel="stark" + {...INSPECTION_SCALE_LABELS.swarmMood} + direction={INSPECTION_SCALE_DIRECTIONS.swarmMood} /> {hintFor("swarmMood")} + )} @@ -785,7 +854,7 @@ function InspectionCaptureForm({ set("feedStatus", feedStatus)} className="grid-cols-2 sm:grid-cols-4" @@ -797,6 +866,7 @@ function InspectionCaptureForm({

) : 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) ? ( + + ) : 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" ? ( + + ) : 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}

+
+ + + {copyNote} + +
+
+ ) : null} + + {isReady && capture.unclear.length > 0 ? ( +
+

+ Das konnte ich nicht zuordnen +

+
    + {capture.unclear.map((sentence) => ( +
  • {sentence}
  • + ))} +
+
+ ) : null} + + {!isGone ? ( +
+ {isReady && openCount > 0 ? ( + + ) : null} + +
+ ) : 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({