diff --git a/messages/de.json b/messages/de.json index b5a71a557..d6a697bfd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7838,11 +7838,12 @@ "kindLabel": "Kategorie", "button": "Hochladen", "uploading": "Wird hochgeladen…", - "fileLabel": "Datei", + "fileLabel": "Datei (erforderlich)", "titleLabel": "Titel (optional)", "titlePlaceholder": "z. B. Kardiologie-Bericht", "dateLabel": "Dokumentdatum (optional)", - "fileRequired": "Wähle eine Datei zum Hochladen." + "fileRequired": "Wähle eine Datei zum Hochladen.", + "accepts": "JPEG, PNG, WebP oder PDF · bis 12 MB" }, "kind": { "DOCTOR_REPORT": "Arztbericht", @@ -7860,7 +7861,8 @@ "loadMore": "Mehr laden", "loadingMore": "Wird geladen…", "pending": "{count} zu prüfen", - "facts": "{count} Fakten" + "facts": "{count} Angaben", + "clearFilters": "Filter zurücksetzen" }, "status": { "EXTRACTING": "Wird gelesen", @@ -7879,7 +7881,9 @@ "reject": "Ablehnen", "edit": "Bearbeiten", "cancel": "Abbrechen", - "save": "Speichern" + "save": "Speichern", + "needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.", + "confidenceLabel": "Erkennungssicherheit" }, "empty": { "noFacts": "Nichts mehr zu prüfen." @@ -7897,17 +7901,21 @@ "dose": "Dosis" }, "toast": { - "uploaded": "Dokument gelesen. Prüfe die Angaben unten.", - "uploadFailed": "Dokument konnte nicht gelesen werden.", + "uploaded": "Dokument gespeichert.", + "uploadFailed": "Dokument konnte nicht gespeichert werden.", "confirmed": "Bestätigte Angaben gespeichert.", "confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.", "saved": "Angabe aktualisiert.", "discarded": "Dokument verworfen.", - "extracted": "Fakten extrahiert. Prüfe sie unten.", - "extractFailed": "Fakten konnten nicht extrahiert werden.", + "extracted": "Angaben ausgelesen. Prüfe sie unten.", + "extractFailed": "Angaben konnten nicht ausgelesen werden.", "updated": "Dokument aktualisiert.", "updateFailed": "Dokument konnte nicht aktualisiert werden.", - "discardFailed": "Dokument konnte nicht verworfen werden." + "discardFailed": "Dokument konnte nicht verworfen werden.", + "uploadTooLarge": "Datei ist zu groß (max. 12 MB).", + "uploadFileType": "Lade eine JPEG-, PNG-, WebP- oder PDF-Datei hoch.", + "uploadRateLimited": "Zu viele Uploads. Versuch es später erneut.", + "uploadInvalidMetadata": "Prüfe Titel, Kategorie oder Datum und versuch es erneut." }, "toolbar": { "searchLabel": "Suche", @@ -7928,10 +7936,10 @@ "detail": { "viewOriginal": "Original ansehen", "edit": "Bearbeiten", - "extract": "Fakten extrahieren", - "extracting": "Wird extrahiert…", + "extract": "Angaben auslesen", + "extracting": "Wird ausgelesen…", "discard": "Verwerfen", - "notExtracted": "Noch keine Fakten extrahiert. Nutze „Fakten extrahieren“, um dieses Dokument zu lesen.", + "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.", @@ -7951,6 +7959,9 @@ "note": { "providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Fakten automatisch zu extrahieren.", "alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden." + }, + "committed": { + "title": "Aus diesem Dokument übernommen" } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index 52b44e938..ee0b673dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7838,11 +7838,12 @@ "kindLabel": "Category", "button": "Upload", "uploading": "Uploading…", - "fileLabel": "File", + "fileLabel": "File (required)", "titleLabel": "Title (optional)", "titlePlaceholder": "e.g. Cardiology report", "dateLabel": "Document date (optional)", - "fileRequired": "Choose a file to upload." + "fileRequired": "Choose a file to upload.", + "accepts": "JPEG, PNG, WebP or PDF · up to 12 MB" }, "kind": { "DOCTOR_REPORT": "Doctor report", @@ -7860,7 +7861,8 @@ "loadMore": "Load more", "loadingMore": "Loading…", "pending": "{count} to review", - "facts": "{count} facts" + "facts": "{count} facts", + "clearFilters": "Clear filters" }, "status": { "EXTRACTING": "Reading", @@ -7879,7 +7881,9 @@ "reject": "Reject", "edit": "Edit", "cancel": "Cancel", - "save": "Save" + "save": "Save", + "needsReviewHint": "Edit this fact to approve it.", + "confidenceLabel": "Extraction confidence" }, "empty": { "noFacts": "Nothing left to review." @@ -7897,8 +7901,8 @@ "dose": "Dose" }, "toast": { - "uploaded": "Document read. Review the facts below.", - "uploadFailed": "Couldn't read the document.", + "uploaded": "Document stored.", + "uploadFailed": "Couldn't store the document.", "confirmed": "Approved facts saved.", "confirmFailed": "Couldn't save your decisions.", "saved": "Fact updated.", @@ -7907,7 +7911,11 @@ "extractFailed": "Couldn't extract facts.", "updated": "Document updated.", "updateFailed": "Couldn't update the document.", - "discardFailed": "Couldn't discard the document." + "discardFailed": "Couldn't discard the document.", + "uploadTooLarge": "File is too large (max 12 MB).", + "uploadFileType": "Upload a JPEG, PNG, WebP, or PDF.", + "uploadRateLimited": "Too many uploads. Try again later.", + "uploadInvalidMetadata": "Check the title, category, or date and try again." }, "toolbar": { "searchLabel": "Search", @@ -7951,6 +7959,9 @@ "note": { "providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically.", "alreadyConfirmed": "This document has already been reviewed, so it can't be extracted again." + }, + "committed": { + "title": "Approved from this document" } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index aef6b734b..7d40e9645 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7838,11 +7838,12 @@ "kindLabel": "Categoría", "button": "Subir", "uploading": "Subiendo…", - "fileLabel": "Archivo", + "fileLabel": "Archivo (obligatorio)", "titleLabel": "Título (opcional)", "titlePlaceholder": "p. ej. Informe de cardiología", "dateLabel": "Fecha del documento (opcional)", - "fileRequired": "Elige un archivo para subir." + "fileRequired": "Elige un archivo para subir.", + "accepts": "JPEG, PNG, WebP o PDF · hasta 12 MB" }, "kind": { "DOCTOR_REPORT": "Informe médico", @@ -7860,7 +7861,8 @@ "loadMore": "Cargar más", "loadingMore": "Cargando…", "pending": "{count} por revisar", - "facts": "{count} datos" + "facts": "{count} datos", + "clearFilters": "Borrar filtros" }, "status": { "EXTRACTING": "Leyendo", @@ -7879,7 +7881,9 @@ "reject": "Rechazar", "edit": "Editar", "cancel": "Cancelar", - "save": "Guardar" + "save": "Guardar", + "needsReviewHint": "Edita este dato para poder aprobarlo.", + "confidenceLabel": "Confianza de la extracción" }, "empty": { "noFacts": "Nada más que revisar." @@ -7897,8 +7901,8 @@ "dose": "Dosis" }, "toast": { - "uploaded": "Documento leído. Revisa los datos a continuación.", - "uploadFailed": "No se pudo leer el documento.", + "uploaded": "Documento guardado.", + "uploadFailed": "No se pudo guardar el documento.", "confirmed": "Datos aprobados guardados.", "confirmFailed": "No se pudieron guardar tus decisiones.", "saved": "Dato actualizado.", @@ -7907,7 +7911,11 @@ "extractFailed": "No se pudieron extraer los datos.", "updated": "Documento actualizado.", "updateFailed": "No se pudo actualizar el documento.", - "discardFailed": "No se pudo descartar el documento." + "discardFailed": "No se pudo descartar el documento.", + "uploadTooLarge": "El archivo es demasiado grande (máx. 12 MB).", + "uploadFileType": "Sube un archivo JPEG, PNG, WebP o PDF.", + "uploadRateLimited": "Demasiadas subidas. Inténtalo de nuevo más tarde.", + "uploadInvalidMetadata": "Revisa el título, la categoría o la fecha e inténtalo de nuevo." }, "toolbar": { "searchLabel": "Buscar", @@ -7951,6 +7959,9 @@ "note": { "providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente.", "alreadyConfirmed": "Este documento ya se ha revisado, por lo que no se puede volver a extraer." + }, + "committed": { + "title": "Aprobado de este documento" } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index 61c28e78e..7b23494c2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7838,11 +7838,12 @@ "kindLabel": "Catégorie", "button": "Téléverser", "uploading": "Téléversement…", - "fileLabel": "Fichier", + "fileLabel": "Fichier (requis)", "titleLabel": "Titre (facultatif)", "titlePlaceholder": "p. ex. Compte rendu de cardiologie", "dateLabel": "Date du document (facultatif)", - "fileRequired": "Choisissez un fichier à téléverser." + "fileRequired": "Choisissez un fichier à téléverser.", + "accepts": "JPEG, PNG, WebP ou PDF · jusqu'à 12 Mo" }, "kind": { "DOCTOR_REPORT": "Compte rendu médical", @@ -7860,7 +7861,8 @@ "loadMore": "Charger plus", "loadingMore": "Chargement…", "pending": "{count} à vérifier", - "facts": "{count} éléments" + "facts": "{count} éléments", + "clearFilters": "Effacer les filtres" }, "status": { "EXTRACTING": "Lecture", @@ -7879,7 +7881,9 @@ "reject": "Rejeter", "edit": "Modifier", "cancel": "Annuler", - "save": "Enregistrer" + "save": "Enregistrer", + "needsReviewHint": "Modifie cette donnée pour pouvoir la valider.", + "confidenceLabel": "Confiance d'extraction" }, "empty": { "noFacts": "Plus rien à vérifier." @@ -7897,8 +7901,8 @@ "dose": "Dose" }, "toast": { - "uploaded": "Document lu. Vérifiez les données ci-dessous.", - "uploadFailed": "Lecture du document impossible.", + "uploaded": "Document enregistré.", + "uploadFailed": "Impossible d'enregistrer le document.", "confirmed": "Données approuvées enregistrées.", "confirmFailed": "Impossible d'enregistrer vos décisions.", "saved": "Donnée mise à jour.", @@ -7907,7 +7911,11 @@ "extractFailed": "Impossible d'extraire les éléments.", "updated": "Document mis à jour.", "updateFailed": "Impossible de mettre à jour le document.", - "discardFailed": "Impossible de supprimer le document." + "discardFailed": "Impossible de supprimer le document.", + "uploadTooLarge": "Le fichier est trop volumineux (max. 12 Mo).", + "uploadFileType": "Importe un fichier JPEG, PNG, WebP ou PDF.", + "uploadRateLimited": "Trop d'envois. Réessaie plus tard.", + "uploadInvalidMetadata": "Vérifie le titre, la catégorie ou la date, puis réessaie." }, "toolbar": { "searchLabel": "Rechercher", @@ -7951,6 +7959,9 @@ "note": { "providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement.", "alreadyConfirmed": "Ce document a déjà été vérifié ; il ne peut pas être extrait à nouveau." + }, + "committed": { + "title": "Validé depuis ce document" } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index 60f4161b3..15c22e93f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7838,11 +7838,12 @@ "kindLabel": "Categoria", "button": "Carica", "uploading": "Caricamento…", - "fileLabel": "File", + "fileLabel": "File (obbligatorio)", "titleLabel": "Titolo (facoltativo)", "titlePlaceholder": "es. Referto di cardiologia", "dateLabel": "Data del documento (facoltativa)", - "fileRequired": "Scegli un file da caricare." + "fileRequired": "Scegli un file da caricare.", + "accepts": "JPEG, PNG, WebP o PDF · fino a 12 MB" }, "kind": { "DOCTOR_REPORT": "Referto medico", @@ -7860,7 +7861,8 @@ "loadMore": "Carica altri", "loadingMore": "Caricamento…", "pending": "{count} da rivedere", - "facts": "{count} dati" + "facts": "{count} dati", + "clearFilters": "Cancella i filtri" }, "status": { "EXTRACTING": "Lettura", @@ -7879,7 +7881,9 @@ "reject": "Rifiuta", "edit": "Modifica", "cancel": "Annulla", - "save": "Salva" + "save": "Salva", + "needsReviewHint": "Modifica questo dato per poterlo approvare.", + "confidenceLabel": "Affidabilità dell'estrazione" }, "empty": { "noFacts": "Niente altro da rivedere." @@ -7897,8 +7901,8 @@ "dose": "Dose" }, "toast": { - "uploaded": "Documento letto. Rivedi i dati qui sotto.", - "uploadFailed": "Impossibile leggere il documento.", + "uploaded": "Documento salvato.", + "uploadFailed": "Impossibile salvare il documento.", "confirmed": "Dati approvati salvati.", "confirmFailed": "Impossibile salvare le tue scelte.", "saved": "Dato aggiornato.", @@ -7907,7 +7911,11 @@ "extractFailed": "Impossibile estrarre i dati.", "updated": "Documento aggiornato.", "updateFailed": "Impossibile aggiornare il documento.", - "discardFailed": "Impossibile eliminare il documento." + "discardFailed": "Impossibile eliminare il documento.", + "uploadTooLarge": "Il file è troppo grande (max 12 MB).", + "uploadFileType": "Carica un file JPEG, PNG, WebP o PDF.", + "uploadRateLimited": "Troppi caricamenti. Riprova più tardi.", + "uploadInvalidMetadata": "Controlla titolo, categoria o data e riprova." }, "toolbar": { "searchLabel": "Cerca", @@ -7951,6 +7959,9 @@ "note": { "providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente.", "alreadyConfirmed": "Questo documento è già stato esaminato, quindi non può essere estratto di nuovo." + }, + "committed": { + "title": "Approvato da questo documento" } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index 0978c5675..b7f3e32ff 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7838,11 +7838,12 @@ "kindLabel": "Kategoria", "button": "Prześlij", "uploading": "Przesyłanie…", - "fileLabel": "Plik", + "fileLabel": "Plik (wymagany)", "titleLabel": "Tytuł (opcjonalnie)", "titlePlaceholder": "np. Raport kardiologiczny", "dateLabel": "Data dokumentu (opcjonalnie)", - "fileRequired": "Wybierz plik do przesłania." + "fileRequired": "Wybierz plik do przesłania.", + "accepts": "JPEG, PNG, WebP lub PDF · do 12 MB" }, "kind": { "DOCTOR_REPORT": "Raport lekarski", @@ -7860,7 +7861,8 @@ "loadMore": "Załaduj więcej", "loadingMore": "Ładowanie…", "pending": "{count} do przeglądu", - "facts": "{count} faktów" + "facts": "{count} faktów", + "clearFilters": "Wyczyść filtry" }, "status": { "EXTRACTING": "Odczyt", @@ -7879,7 +7881,9 @@ "reject": "Odrzuć", "edit": "Edytuj", "cancel": "Anuluj", - "save": "Zapisz" + "save": "Zapisz", + "needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.", + "confidenceLabel": "Pewność rozpoznania" }, "empty": { "noFacts": "Nic więcej do przejrzenia." @@ -7897,8 +7901,8 @@ "dose": "Dawka" }, "toast": { - "uploaded": "Dokument odczytany. Sprawdź dane poniżej.", - "uploadFailed": "Nie udało się odczytać dokumentu.", + "uploaded": "Dokument zapisany.", + "uploadFailed": "Nie udało się zapisać dokumentu.", "confirmed": "Zatwierdzone dane zapisane.", "confirmFailed": "Nie udało się zapisać Twoich decyzji.", "saved": "Dane zaktualizowane.", @@ -7907,7 +7911,11 @@ "extractFailed": "Nie udało się wyodrębnić faktów.", "updated": "Dokument zaktualizowany.", "updateFailed": "Nie udało się zaktualizować dokumentu.", - "discardFailed": "Nie udało się odrzucić dokumentu." + "discardFailed": "Nie udało się odrzucić dokumentu.", + "uploadTooLarge": "Plik jest za duży (maks. 12 MB).", + "uploadFileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", + "uploadRateLimited": "Zbyt wiele przesłań. Spróbuj ponownie później.", + "uploadInvalidMetadata": "Sprawdź tytuł, kategorię lub datę i spróbuj ponownie." }, "toolbar": { "searchLabel": "Szukaj", @@ -7951,6 +7959,9 @@ "note": { "providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty.", "alreadyConfirmed": "Ten dokument został już sprawdzony, więc nie można go ponownie wyodrębnić." + }, + "committed": { + "title": "Zatwierdzone z tego dokumentu" } }, "selfDescription": { diff --git a/src/components/documents/__tests__/inbound-documents-view.test.tsx b/src/components/documents/__tests__/inbound-documents-view.test.tsx index 0d8142429..a38eb13d3 100644 --- a/src/components/documents/__tests__/inbound-documents-view.test.tsx +++ b/src/components/documents/__tests__/inbound-documents-view.test.tsx @@ -128,6 +128,8 @@ describe(" library", () => { expect(html).toContain("Load more"); // A11Y-M2: the row's aria-controls points at the detail region id. expect(html).toContain('aria-controls="documents-detail-d1"'); + // QoL: accepted formats + size ceiling are shown by the file field. + expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB"); }); it("disables the upload button and shows a hint until a file is chosen", () => { @@ -287,6 +289,41 @@ describe("", () => { expect(html).toContain("Imaging"); }); + it("shows the committed facts summary once approved, not the empty note", () => { + const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" }; + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }), + ); + }, + ); + // M6: provenance stays visible — the approved facts are listed read-only… + expect(html).toContain('data-slot="documents-committed"'); + expect(html).toContain("Approved from this document"); + expect(html).toContain("LDL Cholesterol"); + // …instead of stranding the user on "Nothing left to review." + expect(html).not.toContain("Nothing left to review."); + }); + + it("explains why a low-confidence fact can't be approved yet", () => { + const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true }; + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ facts: [lowConf] }), + ); + }, + ); + // M7: the disabled Approve is tied to a plain-language reason. + expect(html).toContain("Edit this fact to approve it."); + expect(html).toContain("Needs review"); + }); + it("offers a confirm step before discarding", () => { const html = render( {}} />, diff --git a/src/components/documents/__tests__/library-utils.test.ts b/src/components/documents/__tests__/library-utils.test.ts index af2816761..9e11e44dc 100644 --- a/src/components/documents/__tests__/library-utils.test.ts +++ b/src/components/documents/__tests__/library-utils.test.ts @@ -4,7 +4,9 @@ import { ApiError } from "@/lib/api/api-fetch"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { buildDocumentListSearch, + classifyUploadError, documentDateKey, + formatDateGroupLabel, groupDocumentsByDate, isAlreadyConfirmedError, isProviderUnsupportedError, @@ -126,13 +128,20 @@ describe("isProviderUnsupportedError", () => { }); describe("isAlreadyConfirmedError", () => { - it("matches the extract 422 already-confirmed signal", () => { - const err = new ApiError("already confirmed", 422, { + it("matches the extract 409 partly-confirmed signal", () => { + const err = new ApiError("already confirmed", 409, { errorCode: "documents.inbound.alreadyPartlyConfirmed", }); expect(isAlreadyConfirmedError(err)).toBe(true); }); + it("matches the extract 422 fully-confirmed signal", () => { + const err = new ApiError("already confirmed", 422, { + errorCode: "documents.inbound.alreadyConfirmed", + }); + expect(isAlreadyConfirmedError(err)).toBe(true); + }); + it("rejects other API errors and non-errors", () => { expect( isAlreadyConfirmedError( @@ -145,3 +154,50 @@ describe("isAlreadyConfirmedError", () => { expect(isAlreadyConfirmedError(null)).toBe(false); }); }); + +describe("classifyUploadError", () => { + it("maps each upload error code to its kind", () => { + const code = (c: string) => + classifyUploadError(new ApiError("x", 400, { errorCode: c })); + expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge"); + expect(code("documents.inbound.fileType")).toBe("fileType"); + expect(code("documents.inbound.rateLimited")).toBe("rateLimited"); + expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata"); + }); + + it("falls back to the HTTP status when the body carries no error code", () => { + expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge"); + expect(classifyUploadError(new ApiError("wrong type", 415))).toBe( + "fileType", + ); + expect(classifyUploadError(new ApiError("slow down", 429))).toBe( + "rateLimited", + ); + }); + + it("returns generic for unknown errors and non-API errors", () => { + expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic"); + expect(classifyUploadError(new Error("network"))).toBe("generic"); + expect(classifyUploadError(null)).toBe("generic"); + }); +}); + +describe("formatDateGroupLabel", () => { + it("delegates the calendar day to the supplied app formatter", () => { + const seen: string[] = []; + const formatDate = (value: string) => { + seen.push(value); + return "FORMATTED"; + }; + expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED"); + // Handed over as a noon-UTC instant so the day never shifts under the tz. + expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]); + }); + + it("returns a malformed key unchanged without calling the formatter", () => { + const formatDate = () => { + throw new Error("should not be called"); + }; + expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date"); + }); +}); diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx index 5afcfb77f..30e86090c 100644 --- a/src/components/documents/inbound-documents-view.tsx +++ b/src/components/documents/inbound-documents-view.tsx @@ -67,7 +67,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import type { DocumentListParams } from "@/lib/query-keys/documents"; import { @@ -84,10 +84,12 @@ import { } from "@/lib/validations/inbound-documents"; import { buildDocumentListSearch, + classifyUploadError, formatDateGroupLabel, groupDocumentsByDate, isAlreadyConfirmedError, isProviderUnsupportedError, + MAX_UPLOAD_BYTES, } from "./library-utils"; type ListResponse = { @@ -97,7 +99,8 @@ type ListResponse = { type Decision = "approve" | "reject"; export function InboundDocumentsView() { - const { t, locale } = useTranslations(); + const { t } = useTranslations(); + const format = useFormatters(); const queryClient = useQueryClient(); const fileRef = useRef(null); @@ -155,6 +158,17 @@ export function InboundDocumentsView() { filters.q || filters.kind || filters.from || filters.to, ); + // Reset search + every filter to the default view. Used by the no-results + // "Clear filters" affordance and after an upload (so the just-stored document + // is in the list the detail panel opens against). + const clearFilters = () => { + setQ(""); + setDebouncedQ(""); + setKindFilter(""); + setFromDate(""); + setToDate(""); + }; + const upload = useMutation({ mutationFn: async (file: File) => { const fd = new FormData(); @@ -170,6 +184,14 @@ 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. + clearFilters(); + setSort("documentDate"); + setOrder("desc"); void invalidateKeys(queryClient, [queryKeys.documents()]); setSelectedId(doc.id); setUploadTitle(""); @@ -177,7 +199,24 @@ export function InboundDocumentsView() { setHasFile(false); if (fileRef.current) fileRef.current.value = ""; }, - onError: () => toast.error(t("documents.toast.uploadFailed")), + onError: (error) => { + switch (classifyUploadError(error)) { + case "tooLarge": + toast.error(t("documents.toast.uploadTooLarge")); + break; + case "fileType": + toast.error(t("documents.toast.uploadFileType")); + break; + case "rateLimited": + toast.error(t("documents.toast.uploadRateLimited")); + break; + case "invalidMetadata": + toast.error(t("documents.toast.uploadInvalidMetadata")); + break; + default: + toast.error(t("documents.toast.uploadFailed")); + } + }, }); return ( @@ -201,6 +240,27 @@ export function InboundDocumentsView() {
+ {/* The one required field goes first and full-width, with the + accepted formats + size ceiling beside it (so the limit is known + before an upload fails). */} +
+ + setHasFile(Boolean(e.target.files?.length))} + /> +

+ {t("documents.upload.accepts")} +

+
-
- - setHasFile(Boolean(e.target.files?.length))} - /> -
+
+ ) : ( +

+ {t("documents.list.empty")} +

+ ) ) : ( <> {groups.map((group) => ( @@ -407,7 +473,7 @@ export function InboundDocumentsView() { className="text-muted-foreground text-xs font-medium tracking-wide uppercase" data-slot="documents-date-group" > - {formatDateGroupLabel(group.key, locale)} + {formatDateGroupLabel(group.key, format.date)} {group.documents.map((doc) => { const expanded = selectedId === doc.id; @@ -518,6 +584,7 @@ export function DocumentDetail({ regionId?: string; }) { const { t } = useTranslations(); + const format = useFormatters(); const queryClient = useQueryClient(); const [decisions, setDecisions] = useState>({}); const [editOpen, setEditOpen] = useState(false); @@ -593,6 +660,10 @@ export function DocumentDetail({ const doc = detail.data; const pending = doc.facts.filter((f) => f.status === "PENDING"); + // Facts already approved from this document and pushed to the structured + // stores — surfaced read-only below so the document ↔ committed-record link + // stays visible after review (instead of the panel going blank). + const approved = doc.facts.filter((f) => f.status === "APPROVED"); const decidedCount = Object.keys(decisions).length; // Mirror the backend's hard guard: extraction is gone once the document is // confirmed OR any fact on it has already been approved (re-extracting would @@ -732,13 +803,36 @@ export function DocumentDetail({ : t("documents.review.confirm")} - ) : ( + ) : approved.length === 0 ? (

{doc.factCount > 0 ? t("documents.empty.noFacts") : t("documents.detail.notExtracted")}

- )} + ) : null} + + {approved.length > 0 ? ( +
+ +

+ {t("documents.committed.title")} +

+
    + {approved.map((fact) => { + const when = factDate(fact); + return ( +
  • + {factSummary(fact)} + {when ? ` · ${format.date(`${when}T12:00:00.000Z`)}` : ""} +
  • + ); + })} +
+
+ ) : null}
@@ -890,6 +984,7 @@ function FactCard({ onDecision: (d: Decision | null) => void; }) { const { t } = useTranslations(); + const format = useFormatters(); const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); @@ -912,7 +1007,7 @@ function FactCard({
-
+
{t(`documents.factType.${fact.factType}`)} @@ -927,8 +1022,14 @@ function FactCard({ {t("documents.review.needsReview")} ) : null} - - {Math.round(fact.confidence * 100)}% + + + {t("documents.review.confidenceLabel")}:{" "} + + {format.percent(fact.confidence)}

{summary}

@@ -937,6 +1038,13 @@ function FactCard({ “{fact.provenance.sourceText}”

) : null} + {fact.needsReview ? ( + // Ties the disabled Approve button to the "Needs review" badge: a + // low-confidence fact must be edited before it can be approved. +

+ {t("documents.review.needsReviewHint")} +

+ ) : null}