mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(documents): surface confirm failures and harden the library edges
Close the gaps the final verification pass flagged on the documents surface: - Confirm no longer claims success when the route rejects a fact into its `failed[]` array (HTTP 200). A unit-mismatch fact left silently PENDING with a green toast now raises a warning naming how many couldn't be saved and why. - Unify the German documents copy on "Angaben": the discard-confirm body, the provider-unsupported note, and the module description still said "Fakten". - After upload, sort by date-added rather than document date so a back-dated upload always lands on the first page where its detail panel can open. - Give the list its own load-error state with a retry instead of rendering the "no documents yet" empty state on a failed fetch. - Classify the local-OCR-disabled extract error to a clear message. - Drop the orphaned settings.sections.notifications.inboxLink key (no call site).
This commit is contained in:
1 parent
2ec4828c1f
commit
679f8a05c9
9 files changed
+254
-33
No files matched your search
+13
-6
@@ -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": {
|
||||
|
||||
+10
-3
@@ -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.",
|
||||
|
||||
+10
-3
@@ -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.",
|
||||
|
||||
+10
-3
@@ -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.",
|
||||
|
||||
+10
-3
@@ -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.",
|
||||
|
||||
+10
-3
@@ -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.",
|
||||
|
||||
@@ -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<unknown>(
|
||||
(acc, part) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
over: Partial<InboundDocumentDto> = {},
|
||||
@@ -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<string, string> = {
|
||||
"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[] = [];
|
||||
|
||||
@@ -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() {
|
||||
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : 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.
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="documents-list-error"
|
||||
className="text-muted-foreground flex flex-col items-start gap-3 py-4 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("documents.list.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void list.refetch()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : documents.length === 0 ? (
|
||||
isFiltered ? (
|
||||
<div
|
||||
@@ -610,22 +644,44 @@ export function DocumentDetail({
|
||||
// A missing provider — or a document already confirmed elsewhere — is not
|
||||
// an error the user acts on here. Surface the calm inline note (rendered
|
||||
// below) instead of an alarming toast.
|
||||
if (!isProviderUnsupportedError(err) && !isAlreadyConfirmedError(err)) {
|
||||
toast.error(t("documents.toast.extractFailed"));
|
||||
if (isProviderUnsupportedError(err) || isAlreadyConfirmedError(err)) {
|
||||
return;
|
||||
}
|
||||
// Local OCR switched off in settings: name the cause rather than the
|
||||
// generic "couldn't extract".
|
||||
if (isLocalOcrDisabledError(err)) {
|
||||
toast.error(t("documents.toast.localOcrDisabled"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("documents.toast.extractFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const confirm = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost(`/api/documents/inbound/${documentId}/confirm`, {
|
||||
mutationFn: (): Promise<ConfirmResponse> =>
|
||||
apiPost<ConfirmResponse>(`/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()]);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user