From 884323d7878a2ee5a039c9777cafd3c12ff5bace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 23:47:23 +0200 Subject: [PATCH] fix(documents): accessibility and review-flow polish for the library UI Tighten the documents library review surface after a QA pass: - Surface the provider-unsupported and already-confirmed notes as polite live regions (role=status / aria-live) so keyboard and screen-reader users hear them when the suppressed-toast paths fire. - Render a document's detail panel directly after the row that opened it, and tie the row's aria-controls to the panel id, fixing reading and tab order. - Gate Discard behind a confirm dialog and toast on failure. - Disable Upload until a file is chosen, with an inline hint. - Namespace per-fact editor field ids so simultaneously-open editors no longer share DOM ids (label-for associations stay correct). - Hide Extract once any fact is approved, mirroring the server guard, and treat the already-confirmed response as a calm inline note. - Add a document-date range filter, a kind fallback for the detail title, and a screen-reader hint on the new-tab original link. - Calmer, neutral needs-review badge and more legible row badges. Adds the new strings across all six locales. --- messages/de.json | 20 +- messages/en.json | 20 +- messages/es.json | 20 +- messages/fr.json | 20 +- messages/it.json | 20 +- messages/pl.json | 20 +- .../__tests__/inbound-documents-view.test.tsx | 103 +++++++- .../documents/__tests__/library-utils.test.ts | 22 ++ .../documents/inbound-documents-view.tsx | 241 ++++++++++++++---- src/components/documents/library-utils.ts | 13 + 10 files changed, 417 insertions(+), 82 deletions(-) diff --git a/messages/de.json b/messages/de.json index ccbf7c61c..ab9cdbf3e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7841,7 +7841,8 @@ "fileLabel": "Datei", "titleLabel": "Titel (optional)", "titlePlaceholder": "z. B. Kardiologie-Bericht", - "dateLabel": "Dokumentdatum (optional)" + "dateLabel": "Dokumentdatum (optional)", + "fileRequired": "Wähle eine Datei zum Hochladen." }, "kind": { "DOCTOR_REPORT": "Arztbericht", @@ -7905,7 +7906,8 @@ "extracted": "Fakten extrahiert. Prüfe sie unten.", "extractFailed": "Fakten konnten nicht extrahiert werden.", "updated": "Dokument aktualisiert.", - "updateFailed": "Dokument konnte nicht aktualisiert werden." + "updateFailed": "Dokument konnte nicht aktualisiert werden.", + "discardFailed": "Dokument konnte nicht verworfen werden." }, "toolbar": { "searchLabel": "Suche", @@ -7919,7 +7921,9 @@ "title": "Titel" }, "orderAsc": "Aufsteigend", - "orderDesc": "Absteigend" + "orderDesc": "Absteigend", + "fromLabel": "Von", + "toLabel": "Bis" }, "detail": { "viewOriginal": "Original ansehen", @@ -7927,7 +7931,12 @@ "extract": "Fakten extrahieren", "extracting": "Wird extrahiert…", "discard": "Verwerfen", - "notExtracted": "Noch keine Fakten extrahiert. Nutze „Fakten extrahieren“, um dieses Dokument zu lesen." + "notExtracted": "Noch keine Fakten extrahiert. Nutze „Fakten extrahieren“, 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.", + "discardConfirmAction": "Verwerfen", + "discardCancel": "Abbrechen" }, "edit": { "title": "Dokument bearbeiten", @@ -7940,7 +7949,8 @@ "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 Fakten automatisch zu extrahieren.", + "alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index deb367851..5370375de 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7841,7 +7841,8 @@ "fileLabel": "File", "titleLabel": "Title (optional)", "titlePlaceholder": "e.g. Cardiology report", - "dateLabel": "Document date (optional)" + "dateLabel": "Document date (optional)", + "fileRequired": "Choose a file to upload." }, "kind": { "DOCTOR_REPORT": "Doctor report", @@ -7905,7 +7906,8 @@ "extracted": "Facts extracted. Review them below.", "extractFailed": "Couldn't extract facts.", "updated": "Document updated.", - "updateFailed": "Couldn't update the document." + "updateFailed": "Couldn't update the document.", + "discardFailed": "Couldn't discard the document." }, "toolbar": { "searchLabel": "Search", @@ -7919,7 +7921,9 @@ "title": "Title" }, "orderAsc": "Ascending", - "orderDesc": "Descending" + "orderDesc": "Descending", + "fromLabel": "From", + "toLabel": "To" }, "detail": { "viewOriginal": "View original", @@ -7927,7 +7931,12 @@ "extract": "Extract facts", "extracting": "Extracting…", "discard": "Discard", - "notExtracted": "No facts extracted yet. Use Extract facts to read this document." + "notExtracted": "No facts extracted yet. Use Extract facts to read this document.", + "viewOriginalNewTab": "(opens in a new tab)", + "discardConfirmTitle": "Discard this document?", + "discardConfirmBody": "This permanently deletes the document, its original file, and any staged facts. This can't be undone.", + "discardConfirmAction": "Discard", + "discardCancel": "Cancel" }, "edit": { "title": "Edit document", @@ -7940,7 +7949,8 @@ "cancel": "Cancel" }, "note": { - "providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically." + "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." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index da3648da8..ba7cdc3f3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7841,7 +7841,8 @@ "fileLabel": "Archivo", "titleLabel": "Título (opcional)", "titlePlaceholder": "p. ej. Informe de cardiología", - "dateLabel": "Fecha del documento (opcional)" + "dateLabel": "Fecha del documento (opcional)", + "fileRequired": "Elige un archivo para subir." }, "kind": { "DOCTOR_REPORT": "Informe médico", @@ -7905,7 +7906,8 @@ "extracted": "Datos extraídos. Revísalos abajo.", "extractFailed": "No se pudieron extraer los datos.", "updated": "Documento actualizado.", - "updateFailed": "No se pudo actualizar el documento." + "updateFailed": "No se pudo actualizar el documento.", + "discardFailed": "No se pudo descartar el documento." }, "toolbar": { "searchLabel": "Buscar", @@ -7919,7 +7921,9 @@ "title": "Título" }, "orderAsc": "Ascendente", - "orderDesc": "Descendente" + "orderDesc": "Descendente", + "fromLabel": "Desde", + "toLabel": "Hasta" }, "detail": { "viewOriginal": "Ver original", @@ -7927,7 +7931,12 @@ "extract": "Extraer datos", "extracting": "Extrayendo…", "discard": "Descartar", - "notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento." + "notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento.", + "viewOriginalNewTab": "(se abre en una pestaña nueva)", + "discardConfirmTitle": "¿Descartar este documento?", + "discardConfirmBody": "Esto elimina de forma permanente el documento, su archivo original y los datos preparados. No se puede deshacer.", + "discardConfirmAction": "Descartar", + "discardCancel": "Cancelar" }, "edit": { "title": "Editar documento", @@ -7940,7 +7949,8 @@ "cancel": "Cancelar" }, "note": { - "providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente." + "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." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index 9950fc359..028940112 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7841,7 +7841,8 @@ "fileLabel": "Fichier", "titleLabel": "Titre (facultatif)", "titlePlaceholder": "p. ex. Compte rendu de cardiologie", - "dateLabel": "Date du document (facultatif)" + "dateLabel": "Date du document (facultatif)", + "fileRequired": "Choisissez un fichier à téléverser." }, "kind": { "DOCTOR_REPORT": "Compte rendu médical", @@ -7905,7 +7906,8 @@ "extracted": "Éléments extraits. Vérifiez-les ci-dessous.", "extractFailed": "Impossible d'extraire les éléments.", "updated": "Document mis à jour.", - "updateFailed": "Impossible de mettre à jour le document." + "updateFailed": "Impossible de mettre à jour le document.", + "discardFailed": "Impossible de supprimer le document." }, "toolbar": { "searchLabel": "Rechercher", @@ -7919,7 +7921,9 @@ "title": "Titre" }, "orderAsc": "Croissant", - "orderDesc": "Décroissant" + "orderDesc": "Décroissant", + "fromLabel": "Du", + "toLabel": "Au" }, "detail": { "viewOriginal": "Voir l'original", @@ -7927,7 +7931,12 @@ "extract": "Extraire les éléments", "extracting": "Extraction…", "discard": "Écarter", - "notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document." + "notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document.", + "viewOriginalNewTab": "(s'ouvre dans un nouvel onglet)", + "discardConfirmTitle": "Supprimer ce document ?", + "discardConfirmBody": "Cela supprime définitivement le document, son fichier d'origine et les données préparées. Cette action est irréversible.", + "discardConfirmAction": "Supprimer", + "discardCancel": "Annuler" }, "edit": { "title": "Modifier le document", @@ -7940,7 +7949,8 @@ "cancel": "Annuler" }, "note": { - "providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement." + "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." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index b614b73d9..a8831d3a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7841,7 +7841,8 @@ "fileLabel": "File", "titleLabel": "Titolo (facoltativo)", "titlePlaceholder": "es. Referto di cardiologia", - "dateLabel": "Data del documento (facoltativa)" + "dateLabel": "Data del documento (facoltativa)", + "fileRequired": "Scegli un file da caricare." }, "kind": { "DOCTOR_REPORT": "Referto medico", @@ -7905,7 +7906,8 @@ "extracted": "Dati estratti. Rivedili qui sotto.", "extractFailed": "Impossibile estrarre i dati.", "updated": "Documento aggiornato.", - "updateFailed": "Impossibile aggiornare il documento." + "updateFailed": "Impossibile aggiornare il documento.", + "discardFailed": "Impossibile eliminare il documento." }, "toolbar": { "searchLabel": "Cerca", @@ -7919,7 +7921,9 @@ "title": "Titolo" }, "orderAsc": "Crescente", - "orderDesc": "Decrescente" + "orderDesc": "Decrescente", + "fromLabel": "Dal", + "toLabel": "Al" }, "detail": { "viewOriginal": "Vedi originale", @@ -7927,7 +7931,12 @@ "extract": "Estrai dati", "extracting": "Estrazione…", "discard": "Scarta", - "notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento." + "notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento.", + "viewOriginalNewTab": "(si apre in una nuova scheda)", + "discardConfirmTitle": "Eliminare questo documento?", + "discardConfirmBody": "Questo elimina definitivamente il documento, il file originale e i dati preparati. L'operazione non è reversibile.", + "discardConfirmAction": "Elimina", + "discardCancel": "Annulla" }, "edit": { "title": "Modifica documento", @@ -7940,7 +7949,8 @@ "cancel": "Annulla" }, "note": { - "providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente." + "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." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index 3a0864934..64065d200 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7841,7 +7841,8 @@ "fileLabel": "Plik", "titleLabel": "Tytuł (opcjonalnie)", "titlePlaceholder": "np. Raport kardiologiczny", - "dateLabel": "Data dokumentu (opcjonalnie)" + "dateLabel": "Data dokumentu (opcjonalnie)", + "fileRequired": "Wybierz plik do przesłania." }, "kind": { "DOCTOR_REPORT": "Raport lekarski", @@ -7905,7 +7906,8 @@ "extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.", "extractFailed": "Nie udało się wyodrębnić faktów.", "updated": "Dokument zaktualizowany.", - "updateFailed": "Nie udało się zaktualizować dokumentu." + "updateFailed": "Nie udało się zaktualizować dokumentu.", + "discardFailed": "Nie udało się odrzucić dokumentu." }, "toolbar": { "searchLabel": "Szukaj", @@ -7919,7 +7921,9 @@ "title": "Tytuł" }, "orderAsc": "Rosnąco", - "orderDesc": "Malejąco" + "orderDesc": "Malejąco", + "fromLabel": "Od", + "toLabel": "Do" }, "detail": { "viewOriginal": "Zobacz oryginał", @@ -7927,7 +7931,12 @@ "extract": "Wyodrębnij fakty", "extracting": "Wyodrębnianie…", "discard": "Odrzuć", - "notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument." + "notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument.", + "viewOriginalNewTab": "(otwiera się w nowej karcie)", + "discardConfirmTitle": "Odrzucić ten dokument?", + "discardConfirmBody": "Spowoduje to trwałe usunięcie dokumentu, oryginalnego pliku i przygotowanych danych. Tej operacji nie można cofnąć.", + "discardConfirmAction": "Odrzuć", + "discardCancel": "Anuluj" }, "edit": { "title": "Edytuj dokument", @@ -7940,7 +7949,8 @@ "cancel": "Anuluj" }, "note": { - "providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty." + "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ć." } }, "selfDescription": { diff --git a/src/components/documents/__tests__/inbound-documents-view.test.tsx b/src/components/documents/__tests__/inbound-documents-view.test.tsx index f414314c9..0d8142429 100644 --- a/src/components/documents/__tests__/inbound-documents-view.test.tsx +++ b/src/components/documents/__tests__/inbound-documents-view.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/lib/api/api-fetch", async () => { }); import { + AlreadyConfirmedNote, DocumentDetail, DocumentMetaEditForm, InboundDocumentsView, @@ -125,6 +126,42 @@ describe(" library", () => { expect(html).toContain("2 to review"); // nextCursor → load-more button. 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"'); + }); + + it("disables the upload button and shows a hint until a file is chosen", () => { + const html = render(, (qc) => { + qc.setQueryData( + queryKeys.inboundDocumentList({ + q: undefined, + kind: undefined, + sort: "documentDate", + order: "desc", + }), + { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, + ); + }); + // A11Y-M4: with no file staged, an inline hint explains why upload is + // disabled (the hint only renders on the !hasFile / not-pending path). + expect(html).toContain("Choose a file to upload."); + }); + + it("renders the document-date range filter inputs", () => { + const html = render(, (qc) => { + qc.setQueryData( + queryKeys.inboundDocumentList({ + q: undefined, + kind: undefined, + sort: "documentDate", + order: "desc", + }), + { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, + ); + }); + // PERF-L: from/to date inputs are wired into the toolbar. + expect(html).toContain('id="doc-from"'); + expect(html).toContain('id="doc-to"'); }); it("shows the empty state when there are no documents", () => { @@ -208,6 +245,58 @@ describe("", () => { ); expect(html).not.toContain('data-slot="documents-extract"'); }); + + it("hides the extract button once any fact is approved (PERF-H1 gate)", () => { + const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" }; + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }), + ); + }, + ); + expect(html).not.toContain('data-slot="documents-extract"'); + }); + + it("threads the region id onto the detail card for aria-controls", () => { + const html = render( + {}} + />, + (qc) => { + qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); + }, + ); + expect(html).toContain('id="documents-detail-d1"'); + }); + + it("falls back to the kind label when title and filename are absent", () => { + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ title: null, filename: null, kind: "IMAGING" }), + ); + }, + ); + expect(html).toContain("Imaging"); + }); + + it("offers a confirm step before discarding", () => { + const html = render( + {}} />, + (qc) => { + qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); + }, + ); + // A11Y-M3: discard is gated behind an AlertDialog trigger, not one click. + expect(html).toContain('data-slot="documents-discard-trigger"'); + }); }); describe("", () => { @@ -227,9 +316,21 @@ describe("", () => { }); describe("", () => { - it("renders the calm inline note, not a hard error", () => { + it("renders the calm inline note as a polite live region (A11Y-H1)", () => { const html = render(); expect(html).toContain('data-slot="documents-provider-note"'); expect(html).toContain("Configure a document-scan provider"); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-live="polite"'); + }); +}); + +describe("", () => { + it("renders a calm polite live region when extraction is refused", () => { + const html = render(); + expect(html).toContain('data-slot="documents-already-confirmed-note"'); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain("already been reviewed"); }); }); diff --git a/src/components/documents/__tests__/library-utils.test.ts b/src/components/documents/__tests__/library-utils.test.ts index 249280a35..5f7892347 100644 --- a/src/components/documents/__tests__/library-utils.test.ts +++ b/src/components/documents/__tests__/library-utils.test.ts @@ -6,6 +6,7 @@ import { buildDocumentListSearch, documentDateKey, groupDocumentsByDate, + isAlreadyConfirmedError, isProviderUnsupportedError, } from "../library-utils"; @@ -123,3 +124,24 @@ describe("isProviderUnsupportedError", () => { expect(isProviderUnsupportedError(null)).toBe(false); }); }); + +describe("isAlreadyConfirmedError", () => { + it("matches the extract 422 already-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( + new ApiError("no provider", 422, { + errorCode: "documents.inbound.providerUnsupported", + }), + ), + ).toBe(false); + expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false); + expect(isAlreadyConfirmedError(null)).toBe(false); + }); +}); diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx index e67388ef7..5afcfb77f 100644 --- a/src/components/documents/inbound-documents-view.tsx +++ b/src/components/documents/inbound-documents-view.tsx @@ -18,7 +18,7 @@ * calm inline note (configure one) — never a hard error; the stored document is * untouched. */ -import { useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { useInfiniteQuery, useMutation, @@ -39,6 +39,17 @@ import { Upload, } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -75,6 +86,7 @@ import { buildDocumentListSearch, formatDateGroupLabel, groupDocumentsByDate, + isAlreadyConfirmedError, isProviderUnsupportedError, } from "./library-utils"; @@ -94,6 +106,9 @@ export function InboundDocumentsView() { const [uploadKind, setUploadKind] = useState("DOCTOR_REPORT"); const [uploadDate, setUploadDate] = useState(""); + // Tracks whether a file is staged in the (uncontrolled) file input so the + // upload button can stay disabled until there's something to upload. + const [hasFile, setHasFile] = useState(false); // Library toolbar state. const [q, setQ] = useState(""); @@ -101,6 +116,8 @@ export function InboundDocumentsView() { const [kindFilter, setKindFilter] = useState<"" | InboundDocumentKindValue>( "", ); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); const [sort, setSort] = useState("documentDate"); const [order, setOrder] = useState<"asc" | "desc">("desc"); @@ -115,6 +132,8 @@ export function InboundDocumentsView() { const filters: DocumentListParams = { q: debouncedQ.trim() || undefined, kind: kindFilter || undefined, + from: fromDate || undefined, + to: toDate || undefined, sort, order, }; @@ -132,7 +151,9 @@ export function InboundDocumentsView() { const documents = list.data?.pages.flatMap((p) => p.documents) ?? []; const groups = groupDocumentsByDate(documents); - const isFiltered = Boolean(filters.q || filters.kind); + const isFiltered = Boolean( + filters.q || filters.kind || filters.from || filters.to, + ); const upload = useMutation({ mutationFn: async (file: File) => { @@ -153,6 +174,7 @@ export function InboundDocumentsView() { setSelectedId(doc.id); setUploadTitle(""); setUploadDate(""); + setHasFile(false); if (fileRef.current) fileRef.current.value = ""; }, onError: () => toast.error(t("documents.toast.uploadFailed")), @@ -232,16 +254,17 @@ export function InboundDocumentsView() { type="file" accept="image/jpeg,image/png,image/webp,application/pdf" disabled={upload.isPending} + onChange={(e) => setHasFile(Boolean(e.target.files?.length))} /> -
+
@@ -301,6 +329,26 @@ export function InboundDocumentsView() { ))}
+
+ + setFromDate(e.target.value)} + /> +
+
+ + setToDate(e.target.value)} + /> +
@@ -361,23 +409,30 @@ export function InboundDocumentsView() { > {formatDateGroupLabel(group.key, locale)} - {group.documents.map((doc) => ( - - setSelectedId(selectedId === doc.id ? null : doc.id) - } - /> - ))} - {selectedId && - group.documents.some((d) => d.id === selectedId) ? ( - setSelectedId(null)} - /> - ) : null} + {group.documents.map((doc) => { + const expanded = selectedId === doc.id; + // Shared id ties the row's aria-controls to the detail + // region so screen readers know the row owns the panel; the + // panel renders directly after its row for correct order. + const detailId = `documents-detail-${doc.id}`; + return ( + + setSelectedId(expanded ? null : doc.id)} + /> + {expanded ? ( + setSelectedId(null)} + /> + ) : null} + + ); + })}
))} @@ -409,10 +464,12 @@ export function InboundDocumentsView() { function DocumentRow({ doc, expanded, + regionId, onToggle, }: { doc: InboundDocumentDto; expanded: boolean; + regionId: string; onToggle: () => void; }) { const { t } = useTranslations(); @@ -422,6 +479,7 @@ function DocumentRow({ + + + + + + + + {t("documents.detail.discardConfirmTitle")} + + + {t("documents.detail.discardConfirmBody")} + + + + + {t("documents.detail.discardCancel")} + + discard.mutate()} + > + {t("documents.detail.discardConfirmAction")} + + + +
{showProviderNote ? : null} + {showAlreadyConfirmedNote ? : null} {pending.length > 0 ? ( <> @@ -654,17 +760,42 @@ export function DocumentDetail({ ); } +/** + * A calm inline note. Carries `role="status"` + `aria-live="polite"` so that + * keyboard / screen-reader users hear it when it appears — these notes stand in + * for a toast that the mutation deliberately suppresses. + */ +function InlineNote({ slot, children }: { slot: string; children: string }) { + return ( +
+ +

{children}

+
+ ); +} + /** The calm inline note shown when extraction needs a provider that's absent. */ export function ProviderUnsupportedNote() { const { t } = useTranslations(); return ( -
- -

{t("documents.note.providerUnsupported")}

-
+ + {t("documents.note.providerUnsupported")} + + ); +} + +/** Calm inline note when extraction is refused — the document is confirmed. */ +export function AlreadyConfirmedNote() { + const { t } = useTranslations(); + return ( + + {t("documents.note.alreadyConfirmed")} + ); } @@ -786,11 +917,12 @@ function FactCard({ {t(`documents.factType.${fact.factType}`)} {fact.needsReview ? ( - // Calm warning (amber), not an alarming destructive red — a - // low-confidence fact is a "please check", not an error. + // A calm, neutral "please check" — not an alarming red, and not + // amber (the app is moving away from amber warning tints). A + // low-confidence fact is a gentle nudge to review, not an error. {t("documents.review.needsReview")} @@ -866,6 +998,7 @@ function FactEditForm({ const d = fact.data as ConditionFactData; return ( {fields.map((f) => (
-
))}
-