mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
84543ad540
commit
884323d787
10 files changed
+417
-82
No files matched your search
+15
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in new issue
Block a user