diff --git a/messages/de.json b/messages/de.json index 877978181..b9e620b72 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4066,7 +4066,6 @@ "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.", "channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter", "channelsHintLink": "Integrationen → Kanäle", - "inboxLink": "Posteingang öffnen", "subtitle": "Wähle, welche Erinnerungen du erhältst." }, "layout": { @@ -7361,7 +7360,7 @@ }, "inboundDocuments": { "label": "Dokumente", - "description": "Speichere deine medizinischen Dokumente; optional ihre Fakten extrahieren und prüfen, bevor sie gespeichert werden. Standardmäßig aus." + "description": "Speichere deine medizinischen Dokumente; optional ihre Angaben auslesen und prüfen, bevor sie gespeichert werden. Standardmäßig aus." }, "mentalHealth": { "label": "Seelisches Wohlbefinden", @@ -7869,7 +7868,8 @@ "loadingMore": "Wird geladen…", "pending": "{count} zu prüfen", "facts": "{count} Angaben", - "clearFilters": "Filter zurücksetzen" + "clearFilters": "Filter zurücksetzen", + "loadError": "Deine Dokumente konnten nicht geladen werden." }, "status": { "EXTRACTING": "Wird gelesen", @@ -7890,7 +7890,12 @@ "cancel": "Abbrechen", "save": "Speichern", "needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.", - "confidenceLabel": "Erkennungssicherheit" + "confidenceLabel": "Erkennungssicherheit", + "commitError": { + "unitMismatch": "die Einheit stimmt nicht mit der gespeicherten überein", + "unitRequired": "ein Zahlenwert braucht eine Einheit", + "generic": "es konnte nicht gespeichert werden" + } }, "empty": { "noFacts": "Nichts mehr zu prüfen." @@ -7911,11 +7916,13 @@ "uploaded": "Dokument gespeichert.", "uploadFailed": "Dokument konnte nicht gespeichert werden.", "confirmed": "Bestätigte Angaben gespeichert.", + "confirmedPartial": "Gespeichert, aber {count} konnten nicht gespeichert werden — {reason}.", "confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.", "saved": "Angabe aktualisiert.", "discarded": "Dokument verworfen.", "extracted": "Angaben ausgelesen. Prüfe sie unten.", "extractFailed": "Angaben konnten nicht ausgelesen werden.", + "localOcrDisabled": "Aktiviere lokale OCR in den Einstellungen, um dieses Dokument auf dem Gerät zu lesen.", "updated": "Dokument aktualisiert.", "updateFailed": "Dokument konnte nicht aktualisiert werden.", "discardFailed": "Dokument konnte nicht verworfen werden.", @@ -7949,7 +7956,7 @@ "notExtracted": "Noch keine Angaben ausgelesen. Nutze „Angaben auslesen“, um dieses Dokument zu lesen.", "viewOriginalNewTab": "(öffnet in einem neuen Tab)", "discardConfirmTitle": "Dieses Dokument verwerfen?", - "discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Fakten dauerhaft. Das lässt sich nicht rückgängig machen.", + "discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Angaben dauerhaft. Das lässt sich nicht rückgängig machen.", "discardConfirmAction": "Verwerfen", "discardCancel": "Abbrechen" }, @@ -7964,7 +7971,7 @@ "cancel": "Abbrechen" }, "note": { - "providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Fakten automatisch zu extrahieren.", + "providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Angaben automatisch auszulesen.", "alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden." }, "committed": { diff --git a/messages/en.json b/messages/en.json index 4d4de8123..2d527bcfd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4066,7 +4066,6 @@ "description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.", "channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under", "channelsHintLink": "Integrations → Channels", - "inboxLink": "Open the inbox", "subtitle": "Choose which reminders you receive." }, "layout": { @@ -7869,7 +7868,8 @@ "loadingMore": "Loading…", "pending": "{count} to review", "facts": "{count} facts", - "clearFilters": "Clear filters" + "clearFilters": "Clear filters", + "loadError": "Couldn't load your documents." }, "status": { "EXTRACTING": "Reading", @@ -7890,7 +7890,12 @@ "cancel": "Cancel", "save": "Save", "needsReviewHint": "Edit this fact to approve it.", - "confidenceLabel": "Extraction confidence" + "confidenceLabel": "Extraction confidence", + "commitError": { + "unitMismatch": "the unit doesn't match the saved one", + "unitRequired": "a numeric value needs a unit", + "generic": "it couldn't be saved" + } }, "empty": { "noFacts": "Nothing left to review." @@ -7911,11 +7916,13 @@ "uploaded": "Document stored.", "uploadFailed": "Couldn't store the document.", "confirmed": "Approved facts saved.", + "confirmedPartial": "Saved, but {count} couldn't be saved — {reason}.", "confirmFailed": "Couldn't save your decisions.", "saved": "Fact updated.", "discarded": "Document discarded.", "extracted": "Facts extracted. Review them below.", "extractFailed": "Couldn't extract facts.", + "localOcrDisabled": "Enable local OCR in settings to read this document on-device.", "updated": "Document updated.", "updateFailed": "Couldn't update the document.", "discardFailed": "Couldn't discard the document.", diff --git a/messages/es.json b/messages/es.json index d8082a86f..ab0958f0a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4066,7 +4066,6 @@ "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.", "channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en", "channelsHintLink": "Integraciones → Canales", - "inboxLink": "Abrir la bandeja de entrada", "subtitle": "Elige qué recordatorios recibes." }, "layout": { @@ -7869,7 +7868,8 @@ "loadingMore": "Cargando…", "pending": "{count} por revisar", "facts": "{count} datos", - "clearFilters": "Borrar filtros" + "clearFilters": "Borrar filtros", + "loadError": "No se pudieron cargar tus documentos." }, "status": { "EXTRACTING": "Leyendo", @@ -7890,7 +7890,12 @@ "cancel": "Cancelar", "save": "Guardar", "needsReviewHint": "Edita este dato para poder aprobarlo.", - "confidenceLabel": "Confianza de la extracción" + "confidenceLabel": "Confianza de la extracción", + "commitError": { + "unitMismatch": "la unidad no coincide con la guardada", + "unitRequired": "un valor numérico necesita una unidad", + "generic": "no se pudo guardar" + } }, "empty": { "noFacts": "Nada más que revisar." @@ -7911,11 +7916,13 @@ "uploaded": "Documento guardado.", "uploadFailed": "No se pudo guardar el documento.", "confirmed": "Datos aprobados guardados.", + "confirmedPartial": "Guardado, pero {count} no se pudieron guardar: {reason}.", "confirmFailed": "No se pudieron guardar tus decisiones.", "saved": "Dato actualizado.", "discarded": "Documento descartado.", "extracted": "Datos extraídos. Revísalos abajo.", "extractFailed": "No se pudieron extraer los datos.", + "localOcrDisabled": "Activa el OCR local en los ajustes para leer este documento en el dispositivo.", "updated": "Documento actualizado.", "updateFailed": "No se pudo actualizar el documento.", "discardFailed": "No se pudo descartar el documento.", diff --git a/messages/fr.json b/messages/fr.json index a6d107c13..32ae4643c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4066,7 +4066,6 @@ "description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.", "channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans", "channelsHintLink": "Intégrations → Canaux", - "inboxLink": "Ouvrir la boîte de réception", "subtitle": "Choisissez les rappels que vous recevez." }, "layout": { @@ -7869,7 +7868,8 @@ "loadingMore": "Chargement…", "pending": "{count} à vérifier", "facts": "{count} éléments", - "clearFilters": "Effacer les filtres" + "clearFilters": "Effacer les filtres", + "loadError": "Impossible de charger vos documents." }, "status": { "EXTRACTING": "Lecture", @@ -7890,7 +7890,12 @@ "cancel": "Annuler", "save": "Enregistrer", "needsReviewHint": "Modifie cette donnée pour pouvoir la valider.", - "confidenceLabel": "Confiance d'extraction" + "confidenceLabel": "Confiance d'extraction", + "commitError": { + "unitMismatch": "l'unité ne correspond pas à celle enregistrée", + "unitRequired": "une valeur numérique nécessite une unité", + "generic": "il n'a pas pu être enregistré" + } }, "empty": { "noFacts": "Plus rien à vérifier." @@ -7911,11 +7916,13 @@ "uploaded": "Document enregistré.", "uploadFailed": "Impossible d'enregistrer le document.", "confirmed": "Données approuvées enregistrées.", + "confirmedPartial": "Enregistré, mais {count} n'ont pas pu être enregistrés — {reason}.", "confirmFailed": "Impossible d'enregistrer vos décisions.", "saved": "Donnée mise à jour.", "discarded": "Document écarté.", "extracted": "Éléments extraits. Vérifiez-les ci-dessous.", "extractFailed": "Impossible d'extraire les éléments.", + "localOcrDisabled": "Activez l'OCR local dans les paramètres pour lire ce document sur l'appareil.", "updated": "Document mis à jour.", "updateFailed": "Impossible de mettre à jour le document.", "discardFailed": "Impossible de supprimer le document.", diff --git a/messages/it.json b/messages/it.json index 86f84119d..331bb87e5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4066,7 +4066,6 @@ "description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.", "channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in", "channelsHintLink": "Integrazioni → Canali", - "inboxLink": "Apri la posta in arrivo", "subtitle": "Scegli quali promemoria ricevere." }, "layout": { @@ -7869,7 +7868,8 @@ "loadingMore": "Caricamento…", "pending": "{count} da rivedere", "facts": "{count} dati", - "clearFilters": "Cancella i filtri" + "clearFilters": "Cancella i filtri", + "loadError": "Impossibile caricare i tuoi documenti." }, "status": { "EXTRACTING": "Lettura", @@ -7890,7 +7890,12 @@ "cancel": "Annulla", "save": "Salva", "needsReviewHint": "Modifica questo dato per poterlo approvare.", - "confidenceLabel": "Affidabilità dell'estrazione" + "confidenceLabel": "Affidabilità dell'estrazione", + "commitError": { + "unitMismatch": "l'unità non corrisponde a quella salvata", + "unitRequired": "un valore numerico richiede un'unità", + "generic": "non è stato possibile salvarlo" + } }, "empty": { "noFacts": "Niente altro da rivedere." @@ -7911,11 +7916,13 @@ "uploaded": "Documento salvato.", "uploadFailed": "Impossibile salvare il documento.", "confirmed": "Dati approvati salvati.", + "confirmedPartial": "Salvato, ma {count} non è stato possibile salvare: {reason}.", "confirmFailed": "Impossibile salvare le tue scelte.", "saved": "Dato aggiornato.", "discarded": "Documento scartato.", "extracted": "Dati estratti. Rivedili qui sotto.", "extractFailed": "Impossibile estrarre i dati.", + "localOcrDisabled": "Attiva l'OCR locale nelle impostazioni per leggere questo documento sul dispositivo.", "updated": "Documento aggiornato.", "updateFailed": "Impossibile aggiornare il documento.", "discardFailed": "Impossibile eliminare il documento.", diff --git a/messages/pl.json b/messages/pl.json index 4b2f924fa..15eb5497a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4066,7 +4066,6 @@ "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.", "channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w", "channelsHintLink": "Integracje → Kanały", - "inboxLink": "Otwórz skrzynkę odbiorczą", "subtitle": "Wybierz, które przypomnienia otrzymujesz." }, "layout": { @@ -7869,7 +7868,8 @@ "loadingMore": "Ładowanie…", "pending": "{count} do przeglądu", "facts": "{count} faktów", - "clearFilters": "Wyczyść filtry" + "clearFilters": "Wyczyść filtry", + "loadError": "Nie udało się wczytać Twoich dokumentów." }, "status": { "EXTRACTING": "Odczyt", @@ -7890,7 +7890,12 @@ "cancel": "Anuluj", "save": "Zapisz", "needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.", - "confidenceLabel": "Pewność rozpoznania" + "confidenceLabel": "Pewność rozpoznania", + "commitError": { + "unitMismatch": "jednostka nie zgadza się z zapisaną", + "unitRequired": "wartość liczbowa wymaga jednostki", + "generic": "nie udało się zapisać" + } }, "empty": { "noFacts": "Nic więcej do przejrzenia." @@ -7911,11 +7916,13 @@ "uploaded": "Dokument zapisany.", "uploadFailed": "Nie udało się zapisać dokumentu.", "confirmed": "Zatwierdzone dane zapisane.", + "confirmedPartial": "Zapisano, ale {count} nie udało się zapisać — {reason}.", "confirmFailed": "Nie udało się zapisać Twoich decyzji.", "saved": "Dane zaktualizowane.", "discarded": "Dokument odrzucony.", "extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.", "extractFailed": "Nie udało się wyodrębnić faktów.", + "localOcrDisabled": "Włącz lokalne OCR w ustawieniach, aby odczytać ten dokument na urządzeniu.", "updated": "Dokument zaktualizowany.", "updateFailed": "Nie udało się zaktualizować dokumentu.", "discardFailed": "Nie udało się odrzucić dokumentu.", diff --git a/src/components/documents/__tests__/library-utils.test.ts b/src/components/documents/__tests__/library-utils.test.ts index 9e11e44dc..944b9abca 100644 --- a/src/components/documents/__tests__/library-utils.test.ts +++ b/src/components/documents/__tests__/library-utils.test.ts @@ -2,16 +2,35 @@ import { describe, it, expect } from "vitest"; import { ApiError } from "@/lib/api/api-fetch"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import enMessages from "../../../../messages/en.json"; import { buildDocumentListSearch, classifyUploadError, + commitFailureReasonKey, + confirmFailureReasonKey, documentDateKey, formatDateGroupLabel, groupDocumentsByDate, isAlreadyConfirmedError, + isLocalOcrDisabledError, isProviderUnsupportedError, } from "../library-utils"; +/** Resolve a dotted i18n key against a bundle (the dynamic reason keys aren't + * literal `t()` call sites, so the call-site coverage guard can't see them — + * assert here that each one actually resolves to a string). */ +function resolveKey(bundle: unknown, key: string): unknown { + return key + .split(".") + .reduce( + (acc, part) => + acc && typeof acc === "object" + ? (acc as Record)[part] + : undefined, + bundle, + ); +} + function doc( id: string, over: Partial = {}, @@ -182,6 +201,67 @@ describe("classifyUploadError", () => { }); }); +describe("isLocalOcrDisabledError", () => { + it("matches the extract 422 local-OCR-disabled signal", () => { + const err = new ApiError("local ocr off", 422, { + errorCode: "documents.inbound.localOcrDisabled", + }); + expect(isLocalOcrDisabledError(err)).toBe(true); + }); + + it("rejects other API errors and non-errors", () => { + expect( + isLocalOcrDisabledError( + new ApiError("no provider", 422, { + errorCode: "documents.inbound.providerUnsupported", + }), + ), + ).toBe(false); + expect(isLocalOcrDisabledError(new Error("boom"))).toBe(false); + expect(isLocalOcrDisabledError(null)).toBe(false); + }); +}); + +describe("commitFailureReasonKey", () => { + it("maps each commit-failure code to a reason key that resolves", () => { + const cases: Record = { + "observation.unitMismatch": "documents.review.commitError.unitMismatch", + "observation.unitRequired": "documents.review.commitError.unitRequired", + "something.unknown": "documents.review.commitError.generic", + }; + for (const [code, key] of Object.entries(cases)) { + expect(commitFailureReasonKey(code)).toBe(key); + // Every reason the toast can surface resolves to a real string. + expect(typeof resolveKey(enMessages, key)).toBe("string"); + } + }); +}); + +describe("confirmFailureReasonKey", () => { + it("surfaces the shared reason when every failed fact agrees", () => { + const key = confirmFailureReasonKey([ + { reason: "observation.unitMismatch" }, + { reason: "observation.unitMismatch" }, + ]); + expect(key).toBe("documents.review.commitError.unitMismatch"); + expect(typeof resolveKey(enMessages, key)).toBe("string"); + }); + + it("falls back to the generic reason when failures disagree", () => { + const key = confirmFailureReasonKey([ + { reason: "observation.unitMismatch" }, + { reason: "observation.unitRequired" }, + ]); + expect(key).toBe("documents.review.commitError.generic"); + }); + + it("uses the single failure's reason for a one-item batch", () => { + expect( + confirmFailureReasonKey([{ reason: "observation.unitRequired" }]), + ).toBe("documents.review.commitError.unitRequired"); + }); +}); + describe("formatDateGroupLabel", () => { it("delegates the calendar day to the supplied app formatter", () => { const seen: string[] = []; diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx index 30e86090c..330c742ec 100644 --- a/src/components/documents/inbound-documents-view.tsx +++ b/src/components/documents/inbound-documents-view.tsx @@ -34,6 +34,7 @@ import { Info, Loader2, Pencil, + RefreshCw, Search, Sparkles, Upload, @@ -85,9 +86,11 @@ import { import { buildDocumentListSearch, classifyUploadError, + confirmFailureReasonKey, formatDateGroupLabel, groupDocumentsByDate, isAlreadyConfirmedError, + isLocalOcrDisabledError, isProviderUnsupportedError, MAX_UPLOAD_BYTES, } from "./library-utils"; @@ -96,6 +99,14 @@ type ListResponse = { documents: InboundDocumentDto[]; nextCursor: string | null; }; +/** Shape the confirm route returns (HTTP 200) — `failed[]` carries per-fact + * commit misses the client must surface, not silently swallow. */ +type ConfirmResponse = { + approved: { factId: string; recordType: string; recordId: string }[]; + rejected: string[]; + needsReview: string[]; + failed: { factId: string; reason: string }[]; +}; type Decision = "approve" | "reject"; export function InboundDocumentsView() { @@ -184,13 +195,15 @@ export function InboundDocumentsView() { }, onSuccess: (doc) => { toast.success(t("documents.toast.uploaded")); - // Drop any active filter / search / sort so the just-stored document is - // in the list — otherwise selecting it opens a detail panel for a row the - // current filter excludes, and the user lands on a dead end. A fresh - // upload files under today, so the default documentDate-desc view shows it - // at the top. + // Drop any active filter / search so the just-stored document is in the + // list — otherwise selecting it opens a detail panel for a row the current + // filter excludes, and the user lands on a dead end. Sort by date-added + // (createdAt) desc, NOT documentDate: a user-set past documentDate would + // otherwise file the fresh upload deep in the list, off page 1, where the + // detail panel never renders. The newest upload always has the newest + // createdAt, so this guarantees it lands first and its panel opens. clearFilters(); - setSort("documentDate"); + setSort("createdAt"); setOrder("desc"); void invalidateKeys(queryClient, [queryKeys.documents()]); setSelectedId(doc.id); @@ -447,6 +460,27 @@ export function InboundDocumentsView() { ))} + ) : list.isError ? ( + // A failed list load is its own state with a retry — never the + // "no documents yet" empty state, which would falsely imply the + // library is empty. +
+ {t("documents.list.loadError")} + +
) : documents.length === 0 ? ( isFiltered ? (
- apiPost(`/api/documents/inbound/${documentId}/confirm`, { + mutationFn: (): Promise => + apiPost(`/api/documents/inbound/${documentId}/confirm`, { decisions: Object.entries(decisions).map(([factId, action]) => ({ factId, action, })), }), - onSuccess: () => { - toast.success(t("documents.toast.confirmed")); + onSuccess: (data) => { + // The route returns HTTP 200 even when it rejects a fact into `failed[]` + // (e.g. a stated unit that disagrees with the saved marker). A green + // "saved" toast would lie — the fact stays PENDING. Surface the count + + // reason so the user knows to reconcile it, and only show the plain + // success when nothing failed. + const failed = data.failed ?? []; + if (failed.length > 0) { + toast.warning( + t("documents.toast.confirmedPartial", { + count: failed.length, + reason: t(confirmFailureReasonKey(failed)), + }), + ); + } else { + toast.success(t("documents.toast.confirmed")); + } setDecisions({}); void invalidateKeys(queryClient, [queryKeys.documents()]); }, diff --git a/src/components/documents/library-utils.ts b/src/components/documents/library-utils.ts index e0d3393c9..db3bb94d5 100644 --- a/src/components/documents/library-utils.ts +++ b/src/components/documents/library-utils.ts @@ -101,6 +101,49 @@ export function isAlreadyConfirmedError(error: unknown): boolean { ); } +/** + * True when an extract failure is the route's "local OCR not enabled" signal + * (`documents.inbound.localOcrDisabled`, 422). The TEXT extract path refuses + * when in-browser OCR is switched off in settings; surface a clear message that + * names the setting rather than the generic "couldn't extract" toast. + */ +export function isLocalOcrDisabledError(error: unknown): boolean { + return ( + error instanceof ApiError && + error.meta?.errorCode === "documents.inbound.localOcrDisabled" + ); +} + +/** + * Map a per-fact commit-failure code to the i18n key for its plain-language + * reason. The confirm route returns HTTP 200 even when it rejects a fact into + * its `failed[]` array (e.g. a stated unit that disagrees with the saved + * marker), so the client must surface why — otherwise the fact silently stays + * PENDING with the user told nothing. + */ +export function commitFailureReasonKey(code: string): string { + switch (code) { + case "observation.unitMismatch": + return "documents.review.commitError.unitMismatch"; + case "observation.unitRequired": + return "documents.review.commitError.unitRequired"; + default: + return "documents.review.commitError.generic"; + } +} + +/** + * Pick the single reason key that best describes a batch of failed facts: the + * shared reason when every failure agrees, else a generic fallback. Used to + * build the partial-failure toast after a confirm that rejected one or more + * facts server-side. + */ +export function confirmFailureReasonKey(failed: { reason: string }[]): string { + const codes = new Set(failed.map((f) => f.reason)); + if (codes.size === 1) return commitFailureReasonKey([...codes][0]); + return "documents.review.commitError.generic"; +} + /** * Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an * oversized file is rejected before it is sent, instead of after a full upload