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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 23:47:23 +02:00
1 parent 84543ad540
commit 884323d787
10 files changed
+417 -82

No files matched your search

+15 -5
View File
@@ -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": {
+15 -5
View File
@@ -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": {
+15 -5
View File
@@ -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": {
+15 -5
View File
@@ -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": {
+15 -5
View File
@@ -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": {
+15 -5
View File
@@ -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": {
@@ -34,6 +34,7 @@ vi.mock("@/lib/api/api-fetch", async () => {
});
import {
AlreadyConfirmedNote,
DocumentDetail,
DocumentMetaEditForm,
InboundDocumentsView,
@@ -125,6 +126,42 @@ describe("<InboundDocumentsView> 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(<InboundDocumentsView />, (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(<InboundDocumentsView />, (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("<DocumentDetail>", () => {
);
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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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(
<DocumentDetail
documentId="d1"
regionId="documents-detail-d1"
onClosed={() => {}}
/>,
(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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(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("<DocumentMetaEditForm>", () => {
@@ -227,9 +316,21 @@ describe("<DocumentMetaEditForm>", () => {
});
describe("<ProviderUnsupportedNote>", () => {
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(<ProviderUnsupportedNote />);
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("<AlreadyConfirmedNote>", () => {
it("renders a calm polite live region when extraction is refused", () => {
const html = render(<AlreadyConfirmedNote />);
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");
});
});
@@ -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);
});
});
@@ -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<InboundDocumentKindValue>("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<DocumentListSort>("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))}
/>
</div>
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
const file = fileRef.current?.files?.[0];
if (file) upload.mutate(file);
}}
disabled={upload.isPending}
disabled={upload.isPending || !hasFile}
>
{upload.isPending ? (
<Loader2
@@ -255,6 +278,11 @@ export function InboundDocumentsView() {
? t("documents.upload.uploading")
: t("documents.upload.button")}
</Button>
{!hasFile && !upload.isPending ? (
<p className="text-muted-foreground text-sm">
{t("documents.upload.fileRequired")}
</p>
) : null}
</div>
</CardContent>
</Card>
@@ -301,6 +329,26 @@ export function InboundDocumentsView() {
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-from">{t("documents.toolbar.fromLabel")}</Label>
<Input
id="doc-from"
type="date"
value={fromDate}
max={toDate || undefined}
onChange={(e) => setFromDate(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-to">{t("documents.toolbar.toLabel")}</Label>
<Input
id="doc-to"
type="date"
value={toDate}
min={fromDate || undefined}
onChange={(e) => setToDate(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-sort">{t("documents.toolbar.sortLabel")}</Label>
<div className="flex items-center gap-1">
@@ -361,23 +409,30 @@ export function InboundDocumentsView() {
>
{formatDateGroupLabel(group.key, locale)}
</h2>
{group.documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
expanded={selectedId === doc.id}
onToggle={() =>
setSelectedId(selectedId === doc.id ? null : doc.id)
}
/>
))}
{selectedId &&
group.documents.some((d) => d.id === selectedId) ? (
<DocumentDetail
documentId={selectedId}
onClosed={() => 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 (
<Fragment key={doc.id}>
<DocumentRow
doc={doc}
expanded={expanded}
regionId={detailId}
onToggle={() => setSelectedId(expanded ? null : doc.id)}
/>
{expanded ? (
<DocumentDetail
regionId={detailId}
documentId={doc.id}
onClosed={() => setSelectedId(null)}
/>
) : null}
</Fragment>
);
})}
</div>
))}
@@ -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({
<button
type="button"
aria-expanded={expanded}
aria-controls={regionId}
onClick={onToggle}
data-slot="documents-row"
className="hover:bg-accent flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm"
@@ -429,10 +487,10 @@ function DocumentRow({
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{display}</span>
<span className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
<Badge variant="outline" className="text-xs">
{t(`documents.kind.${doc.kind}`)}
</Badge>
<Badge variant="secondary" className="text-[10px]">
<Badge variant="secondary" className="text-xs">
{t(`documents.status.${doc.status}`)}
</Badge>
</span>
@@ -453,9 +511,11 @@ function DocumentRow({
export function DocumentDetail({
documentId,
onClosed,
regionId,
}: {
documentId: string;
onClosed: () => void;
regionId?: string;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -480,9 +540,10 @@ export function DocumentDetail({
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
onError: (err) => {
// A missing provider is not an error the user can fix here — surface the
// calm inline note (rendered below) instead of an alarming toast.
if (!isProviderUnsupportedError(err)) {
// 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"));
}
},
@@ -511,6 +572,7 @@ export function DocumentDetail({
void invalidateKeys(queryClient, [queryKeys.documents()]);
onClosed();
},
onError: () => toast.error(t("documents.toast.discardFailed")),
});
if (detail.isLoading) {
@@ -532,13 +594,20 @@ export function DocumentDetail({
const doc = detail.data;
const pending = doc.facts.filter((f) => f.status === "PENDING");
const decidedCount = Object.keys(decisions).length;
const canExtract = doc.status !== "CONFIRMED";
// 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
// clash with facts already committed to the structured stores).
const hasApprovedFact = doc.facts.some((f) => f.status === "APPROVED");
const canExtract = doc.status !== "CONFIRMED" && !hasApprovedFact;
const showProviderNote = isProviderUnsupportedError(extract.error);
const showAlreadyConfirmedNote = isAlreadyConfirmedError(extract.error);
const detailTitle =
doc.title ?? doc.filename ?? t(`documents.kind.${doc.kind}`);
return (
<Card data-slot="documents-detail">
<Card id={regionId} data-slot="documents-detail">
<CardHeader className="flex-col items-start gap-2 space-y-0">
<CardTitle className="text-base">{doc.title ?? doc.filename}</CardTitle>
<CardTitle className="text-base">{detailTitle}</CardTitle>
<div className="flex flex-wrap items-center gap-1">
<Button asChild variant="ghost" size="sm">
{/* Same-origin, cookie-authenticated: a new tab carries the
@@ -550,6 +619,10 @@ export function DocumentDetail({
>
<ExternalLink className="h-4 w-4" aria-hidden />
{t("documents.detail.viewOriginal")}
<span className="sr-only">
{" "}
{t("documents.detail.viewOriginalNewTab")}
</span>
</a>
</Button>
<Button
@@ -582,18 +655,51 @@ export function DocumentDetail({
: t("documents.detail.extract")}
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => discard.mutate()}
disabled={discard.isPending}
>
{t("documents.detail.discard")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={discard.isPending}
data-slot="documents-discard-trigger"
>
{discard.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("documents.detail.discard")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("documents.detail.discardConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("documents.detail.discardConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("documents.detail.discardCancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={discard.isPending}
aria-busy={discard.isPending || undefined}
onClick={() => discard.mutate()}
>
{t("documents.detail.discardConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{showProviderNote ? <ProviderUnsupportedNote /> : null}
{showAlreadyConfirmedNote ? <AlreadyConfirmedNote /> : 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 (
<div
role="status"
aria-live="polite"
className="flex items-start gap-2 rounded-md border border-sky-300 bg-sky-50 p-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-200"
data-slot={slot}
>
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<p>{children}</p>
</div>
);
}
/** The calm inline note shown when extraction needs a provider that's absent. */
export function ProviderUnsupportedNote() {
const { t } = useTranslations();
return (
<div
className="flex items-start gap-2 rounded-md border border-sky-300 bg-sky-50 p-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-200"
data-slot="documents-provider-note"
>
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<p>{t("documents.note.providerUnsupported")}</p>
</div>
<InlineNote slot="documents-provider-note">
{t("documents.note.providerUnsupported")}
</InlineNote>
);
}
/** Calm inline note when extraction is refused — the document is confirmed. */
export function AlreadyConfirmedNote() {
const { t } = useTranslations();
return (
<InlineNote slot="documents-already-confirmed-note">
{t("documents.note.alreadyConfirmed")}
</InlineNote>
);
}
@@ -786,11 +917,12 @@ function FactCard({
{t(`documents.factType.${fact.factType}`)}
</Badge>
{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.
<Badge
variant="outline"
className="border-amber-400 text-amber-700 dark:border-amber-900/60 dark:text-amber-400"
className="border-foreground/25 text-muted-foreground"
>
{t("documents.review.needsReview")}
</Badge>
@@ -866,6 +998,7 @@ function FactEditForm({
const d = fact.data as ConditionFactData;
return (
<EditFields
idPrefix={fact.id}
pending={pending}
fields={[
{ key: "label", label: t("documents.fields.label"), value: d.label },
@@ -885,6 +1018,7 @@ function FactEditForm({
const d = fact.data as ObservationFactData;
return (
<EditFields
idPrefix={fact.id}
pending={pending}
fields={[
{ key: "label", label: t("documents.fields.label"), value: d.label },
@@ -918,6 +1052,7 @@ function FactEditForm({
const d = fact.data as MedicationStatementFactData;
return (
<EditFields
idPrefix={fact.id}
pending={pending}
fields={[
{ key: "name", label: t("documents.fields.label"), value: d.name },
@@ -937,11 +1072,15 @@ function FactEditForm({
}
function EditFields({
idPrefix,
fields,
dateValue,
pending,
onSubmit,
}: {
// Per-fact id namespace so simultaneously-open editors never share DOM ids
// (which would break every label's htmlFor → focus association).
idPrefix: string;
fields: { key: string; label: string; value: string }[];
dateValue: string | null;
pending: boolean;
@@ -957,11 +1096,11 @@ function EditFields({
<div className="mt-2 flex flex-col gap-2 border-t pt-2">
{fields.map((f) => (
<div key={f.key} className="flex flex-col gap-1">
<Label htmlFor={`edit-${f.key}`} className="text-xs">
<Label htmlFor={`edit-${idPrefix}-${f.key}`} className="text-xs">
{f.label}
</Label>
<Input
id={`edit-${f.key}`}
id={`edit-${idPrefix}-${f.key}`}
value={vals[f.key] ?? ""}
onChange={(e) =>
setVals((p) => ({ ...p, [f.key]: e.target.value }))
@@ -970,11 +1109,11 @@ function EditFields({
</div>
))}
<div className="flex flex-col gap-1">
<Label htmlFor="edit-date" className="text-xs">
<Label htmlFor={`edit-${idPrefix}-date`} className="text-xs">
{t("documents.fields.date")}
</Label>
<Input
id="edit-date"
id={`edit-${idPrefix}-date`}
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
+13
View File
@@ -82,6 +82,19 @@ export function isProviderUnsupportedError(error: unknown): boolean {
);
}
/**
* True when an error is the extract route's 422 "this document has already been
* confirmed" signal. Re-extraction is refused server-side once any fact has
* been committed; the UI surfaces this as a calm inline note rather than a hard
* error toast, and hides the Extract control to match.
*/
export function isAlreadyConfirmedError(error: unknown): boolean {
return (
error instanceof ApiError &&
error.meta?.errorCode === "documents.inbound.alreadyConfirmed"
);
}
/** Format a YYYY-MM-DD group key into a locale-aware medium date label. */
export function formatDateGroupLabel(key: string, locale: string): string {
const date = new Date(`${key}T00:00:00`);