fix(documents): reconcile library quality-of-life passes

- Reword the upload toasts to the store-only reality ("stored", not "read"),
  so the success message no longer claims AI ran and the panel it opens no
  longer contradicts it.
- Classify upload failures (too large / wrong type / rate-limited / invalid
  metadata) into specific messages; add a client-side 12 MB pre-check and show
  the accepted formats and size ceiling beside the file field.
- Clear filters, search, and sort on a successful upload so the new document is
  in the list the detail panel opens against, and add a "Clear filters" action
  on the no-results state.
- List approved facts read-only after review so the link between a document and
  the records it contributed stays visible instead of "Nothing left to review".
- Format date-group headers through the app's own date formatter, so they honour
  the user's date-format preference instead of a textual medium style.
- Stack the toolbar until md and let it wrap; wrap the fact badge row on narrow
  screens.
- Tie the disabled Approve to a plain-language reason, label the confidence
  percentage, and render it through the locale percent formatter.
- Treat a fully-confirmed re-extract (422) as the calm already-reviewed note,
  matching the partly-confirmed case.
- German: unify on "Angaben" for the extracted items and drop the anglicism.
This commit is contained in:
Marc-André Bombeck committed 2026-06-29 00:33:30 +02:00
1 parent 4702546cdb
commit e7683c9f54
10 files changed
+423 -95

No files matched your search

+23 -12
View File
@@ -7838,11 +7838,12 @@
"kindLabel": "Kategorie",
"button": "Hochladen",
"uploading": "Wird hochgeladen…",
"fileLabel": "Datei",
"fileLabel": "Datei (erforderlich)",
"titleLabel": "Titel (optional)",
"titlePlaceholder": "z. B. Kardiologie-Bericht",
"dateLabel": "Dokumentdatum (optional)",
"fileRequired": "Wähle eine Datei zum Hochladen."
"fileRequired": "Wähle eine Datei zum Hochladen.",
"accepts": "JPEG, PNG, WebP oder PDF · bis 12 MB"
},
"kind": {
"DOCTOR_REPORT": "Arztbericht",
@@ -7860,7 +7861,8 @@
"loadMore": "Mehr laden",
"loadingMore": "Wird geladen…",
"pending": "{count} zu prüfen",
"facts": "{count} Fakten"
"facts": "{count} Angaben",
"clearFilters": "Filter zurücksetzen"
},
"status": {
"EXTRACTING": "Wird gelesen",
@@ -7879,7 +7881,9 @@
"reject": "Ablehnen",
"edit": "Bearbeiten",
"cancel": "Abbrechen",
"save": "Speichern"
"save": "Speichern",
"needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.",
"confidenceLabel": "Erkennungssicherheit"
},
"empty": {
"noFacts": "Nichts mehr zu prüfen."
@@ -7897,17 +7901,21 @@
"dose": "Dosis"
},
"toast": {
"uploaded": "Dokument gelesen. Prüfe die Angaben unten.",
"uploadFailed": "Dokument konnte nicht gelesen werden.",
"uploaded": "Dokument gespeichert.",
"uploadFailed": "Dokument konnte nicht gespeichert werden.",
"confirmed": "Bestätigte Angaben gespeichert.",
"confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.",
"saved": "Angabe aktualisiert.",
"discarded": "Dokument verworfen.",
"extracted": "Fakten extrahiert. Prüfe sie unten.",
"extractFailed": "Fakten konnten nicht extrahiert werden.",
"extracted": "Angaben ausgelesen. Prüfe sie unten.",
"extractFailed": "Angaben konnten nicht ausgelesen werden.",
"updated": "Dokument aktualisiert.",
"updateFailed": "Dokument konnte nicht aktualisiert werden.",
"discardFailed": "Dokument konnte nicht verworfen werden."
"discardFailed": "Dokument konnte nicht verworfen werden.",
"uploadTooLarge": "Datei ist zu groß (max. 12 MB).",
"uploadFileType": "Lade eine JPEG-, PNG-, WebP- oder PDF-Datei hoch.",
"uploadRateLimited": "Zu viele Uploads. Versuch es später erneut.",
"uploadInvalidMetadata": "Prüfe Titel, Kategorie oder Datum und versuch es erneut."
},
"toolbar": {
"searchLabel": "Suche",
@@ -7928,10 +7936,10 @@
"detail": {
"viewOriginal": "Original ansehen",
"edit": "Bearbeiten",
"extract": "Fakten extrahieren",
"extracting": "Wird extrahiert…",
"extract": "Angaben auslesen",
"extracting": "Wird ausgelesen…",
"discard": "Verwerfen",
"notExtracted": "Noch keine Fakten extrahiert. Nutze „Fakten extrahieren“, um dieses Dokument zu lesen.",
"notExtracted": "Noch keine Angaben ausgelesen. Nutze „Angaben auslesen“, um dieses Dokument zu lesen.",
"viewOriginalNewTab": "(öffnet in einem neuen Tab)",
"discardConfirmTitle": "Dieses Dokument verwerfen?",
"discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Fakten dauerhaft. Das lässt sich nicht rückgängig machen.",
@@ -7951,6 +7959,9 @@
"note": {
"providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Fakten automatisch zu extrahieren.",
"alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden."
},
"committed": {
"title": "Aus diesem Dokument übernommen"
}
},
"selfDescription": {
+18 -7
View File
@@ -7838,11 +7838,12 @@
"kindLabel": "Category",
"button": "Upload",
"uploading": "Uploading…",
"fileLabel": "File",
"fileLabel": "File (required)",
"titleLabel": "Title (optional)",
"titlePlaceholder": "e.g. Cardiology report",
"dateLabel": "Document date (optional)",
"fileRequired": "Choose a file to upload."
"fileRequired": "Choose a file to upload.",
"accepts": "JPEG, PNG, WebP or PDF · up to 12 MB"
},
"kind": {
"DOCTOR_REPORT": "Doctor report",
@@ -7860,7 +7861,8 @@
"loadMore": "Load more",
"loadingMore": "Loading…",
"pending": "{count} to review",
"facts": "{count} facts"
"facts": "{count} facts",
"clearFilters": "Clear filters"
},
"status": {
"EXTRACTING": "Reading",
@@ -7879,7 +7881,9 @@
"reject": "Reject",
"edit": "Edit",
"cancel": "Cancel",
"save": "Save"
"save": "Save",
"needsReviewHint": "Edit this fact to approve it.",
"confidenceLabel": "Extraction confidence"
},
"empty": {
"noFacts": "Nothing left to review."
@@ -7897,8 +7901,8 @@
"dose": "Dose"
},
"toast": {
"uploaded": "Document read. Review the facts below.",
"uploadFailed": "Couldn't read the document.",
"uploaded": "Document stored.",
"uploadFailed": "Couldn't store the document.",
"confirmed": "Approved facts saved.",
"confirmFailed": "Couldn't save your decisions.",
"saved": "Fact updated.",
@@ -7907,7 +7911,11 @@
"extractFailed": "Couldn't extract facts.",
"updated": "Document updated.",
"updateFailed": "Couldn't update the document.",
"discardFailed": "Couldn't discard the document."
"discardFailed": "Couldn't discard the document.",
"uploadTooLarge": "File is too large (max 12 MB).",
"uploadFileType": "Upload a JPEG, PNG, WebP, or PDF.",
"uploadRateLimited": "Too many uploads. Try again later.",
"uploadInvalidMetadata": "Check the title, category, or date and try again."
},
"toolbar": {
"searchLabel": "Search",
@@ -7951,6 +7959,9 @@
"note": {
"providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically.",
"alreadyConfirmed": "This document has already been reviewed, so it can't be extracted again."
},
"committed": {
"title": "Approved from this document"
}
},
"selfDescription": {
+18 -7
View File
@@ -7838,11 +7838,12 @@
"kindLabel": "Categoría",
"button": "Subir",
"uploading": "Subiendo…",
"fileLabel": "Archivo",
"fileLabel": "Archivo (obligatorio)",
"titleLabel": "Título (opcional)",
"titlePlaceholder": "p. ej. Informe de cardiología",
"dateLabel": "Fecha del documento (opcional)",
"fileRequired": "Elige un archivo para subir."
"fileRequired": "Elige un archivo para subir.",
"accepts": "JPEG, PNG, WebP o PDF · hasta 12 MB"
},
"kind": {
"DOCTOR_REPORT": "Informe médico",
@@ -7860,7 +7861,8 @@
"loadMore": "Cargar más",
"loadingMore": "Cargando…",
"pending": "{count} por revisar",
"facts": "{count} datos"
"facts": "{count} datos",
"clearFilters": "Borrar filtros"
},
"status": {
"EXTRACTING": "Leyendo",
@@ -7879,7 +7881,9 @@
"reject": "Rechazar",
"edit": "Editar",
"cancel": "Cancelar",
"save": "Guardar"
"save": "Guardar",
"needsReviewHint": "Edita este dato para poder aprobarlo.",
"confidenceLabel": "Confianza de la extracción"
},
"empty": {
"noFacts": "Nada más que revisar."
@@ -7897,8 +7901,8 @@
"dose": "Dosis"
},
"toast": {
"uploaded": "Documento leído. Revisa los datos a continuación.",
"uploadFailed": "No se pudo leer el documento.",
"uploaded": "Documento guardado.",
"uploadFailed": "No se pudo guardar el documento.",
"confirmed": "Datos aprobados guardados.",
"confirmFailed": "No se pudieron guardar tus decisiones.",
"saved": "Dato actualizado.",
@@ -7907,7 +7911,11 @@
"extractFailed": "No se pudieron extraer los datos.",
"updated": "Documento actualizado.",
"updateFailed": "No se pudo actualizar el documento.",
"discardFailed": "No se pudo descartar el documento."
"discardFailed": "No se pudo descartar el documento.",
"uploadTooLarge": "El archivo es demasiado grande (máx. 12 MB).",
"uploadFileType": "Sube un archivo JPEG, PNG, WebP o PDF.",
"uploadRateLimited": "Demasiadas subidas. Inténtalo de nuevo más tarde.",
"uploadInvalidMetadata": "Revisa el título, la categoría o la fecha e inténtalo de nuevo."
},
"toolbar": {
"searchLabel": "Buscar",
@@ -7951,6 +7959,9 @@
"note": {
"providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente.",
"alreadyConfirmed": "Este documento ya se ha revisado, por lo que no se puede volver a extraer."
},
"committed": {
"title": "Aprobado de este documento"
}
},
"selfDescription": {
+18 -7
View File
@@ -7838,11 +7838,12 @@
"kindLabel": "Catégorie",
"button": "Téléverser",
"uploading": "Téléversement…",
"fileLabel": "Fichier",
"fileLabel": "Fichier (requis)",
"titleLabel": "Titre (facultatif)",
"titlePlaceholder": "p. ex. Compte rendu de cardiologie",
"dateLabel": "Date du document (facultatif)",
"fileRequired": "Choisissez un fichier à téléverser."
"fileRequired": "Choisissez un fichier à téléverser.",
"accepts": "JPEG, PNG, WebP ou PDF · jusqu'à 12 Mo"
},
"kind": {
"DOCTOR_REPORT": "Compte rendu médical",
@@ -7860,7 +7861,8 @@
"loadMore": "Charger plus",
"loadingMore": "Chargement…",
"pending": "{count} à vérifier",
"facts": "{count} éléments"
"facts": "{count} éléments",
"clearFilters": "Effacer les filtres"
},
"status": {
"EXTRACTING": "Lecture",
@@ -7879,7 +7881,9 @@
"reject": "Rejeter",
"edit": "Modifier",
"cancel": "Annuler",
"save": "Enregistrer"
"save": "Enregistrer",
"needsReviewHint": "Modifie cette donnée pour pouvoir la valider.",
"confidenceLabel": "Confiance d'extraction"
},
"empty": {
"noFacts": "Plus rien à vérifier."
@@ -7897,8 +7901,8 @@
"dose": "Dose"
},
"toast": {
"uploaded": "Document lu. Vérifiez les données ci-dessous.",
"uploadFailed": "Lecture du document impossible.",
"uploaded": "Document enregistré.",
"uploadFailed": "Impossible d'enregistrer le document.",
"confirmed": "Données approuvées enregistrées.",
"confirmFailed": "Impossible d'enregistrer vos décisions.",
"saved": "Donnée mise à jour.",
@@ -7907,7 +7911,11 @@
"extractFailed": "Impossible d'extraire les éléments.",
"updated": "Document mis à jour.",
"updateFailed": "Impossible de mettre à jour le document.",
"discardFailed": "Impossible de supprimer le document."
"discardFailed": "Impossible de supprimer le document.",
"uploadTooLarge": "Le fichier est trop volumineux (max. 12 Mo).",
"uploadFileType": "Importe un fichier JPEG, PNG, WebP ou PDF.",
"uploadRateLimited": "Trop d'envois. Réessaie plus tard.",
"uploadInvalidMetadata": "Vérifie le titre, la catégorie ou la date, puis réessaie."
},
"toolbar": {
"searchLabel": "Rechercher",
@@ -7951,6 +7959,9 @@
"note": {
"providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement.",
"alreadyConfirmed": "Ce document a déjà été vérifié ; il ne peut pas être extrait à nouveau."
},
"committed": {
"title": "Validé depuis ce document"
}
},
"selfDescription": {
+18 -7
View File
@@ -7838,11 +7838,12 @@
"kindLabel": "Categoria",
"button": "Carica",
"uploading": "Caricamento…",
"fileLabel": "File",
"fileLabel": "File (obbligatorio)",
"titleLabel": "Titolo (facoltativo)",
"titlePlaceholder": "es. Referto di cardiologia",
"dateLabel": "Data del documento (facoltativa)",
"fileRequired": "Scegli un file da caricare."
"fileRequired": "Scegli un file da caricare.",
"accepts": "JPEG, PNG, WebP o PDF · fino a 12 MB"
},
"kind": {
"DOCTOR_REPORT": "Referto medico",
@@ -7860,7 +7861,8 @@
"loadMore": "Carica altri",
"loadingMore": "Caricamento…",
"pending": "{count} da rivedere",
"facts": "{count} dati"
"facts": "{count} dati",
"clearFilters": "Cancella i filtri"
},
"status": {
"EXTRACTING": "Lettura",
@@ -7879,7 +7881,9 @@
"reject": "Rifiuta",
"edit": "Modifica",
"cancel": "Annulla",
"save": "Salva"
"save": "Salva",
"needsReviewHint": "Modifica questo dato per poterlo approvare.",
"confidenceLabel": "Affidabilità dell'estrazione"
},
"empty": {
"noFacts": "Niente altro da rivedere."
@@ -7897,8 +7901,8 @@
"dose": "Dose"
},
"toast": {
"uploaded": "Documento letto. Rivedi i dati qui sotto.",
"uploadFailed": "Impossibile leggere il documento.",
"uploaded": "Documento salvato.",
"uploadFailed": "Impossibile salvare il documento.",
"confirmed": "Dati approvati salvati.",
"confirmFailed": "Impossibile salvare le tue scelte.",
"saved": "Dato aggiornato.",
@@ -7907,7 +7911,11 @@
"extractFailed": "Impossibile estrarre i dati.",
"updated": "Documento aggiornato.",
"updateFailed": "Impossibile aggiornare il documento.",
"discardFailed": "Impossibile eliminare il documento."
"discardFailed": "Impossibile eliminare il documento.",
"uploadTooLarge": "Il file è troppo grande (max 12 MB).",
"uploadFileType": "Carica un file JPEG, PNG, WebP o PDF.",
"uploadRateLimited": "Troppi caricamenti. Riprova più tardi.",
"uploadInvalidMetadata": "Controlla titolo, categoria o data e riprova."
},
"toolbar": {
"searchLabel": "Cerca",
@@ -7951,6 +7959,9 @@
"note": {
"providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente.",
"alreadyConfirmed": "Questo documento è già stato esaminato, quindi non può essere estratto di nuovo."
},
"committed": {
"title": "Approvato da questo documento"
}
},
"selfDescription": {
+18 -7
View File
@@ -7838,11 +7838,12 @@
"kindLabel": "Kategoria",
"button": "Prześlij",
"uploading": "Przesyłanie…",
"fileLabel": "Plik",
"fileLabel": "Plik (wymagany)",
"titleLabel": "Tytuł (opcjonalnie)",
"titlePlaceholder": "np. Raport kardiologiczny",
"dateLabel": "Data dokumentu (opcjonalnie)",
"fileRequired": "Wybierz plik do przesłania."
"fileRequired": "Wybierz plik do przesłania.",
"accepts": "JPEG, PNG, WebP lub PDF · do 12 MB"
},
"kind": {
"DOCTOR_REPORT": "Raport lekarski",
@@ -7860,7 +7861,8 @@
"loadMore": "Załaduj więcej",
"loadingMore": "Ładowanie…",
"pending": "{count} do przeglądu",
"facts": "{count} faktów"
"facts": "{count} faktów",
"clearFilters": "Wyczyść filtry"
},
"status": {
"EXTRACTING": "Odczyt",
@@ -7879,7 +7881,9 @@
"reject": "Odrzuć",
"edit": "Edytuj",
"cancel": "Anuluj",
"save": "Zapisz"
"save": "Zapisz",
"needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.",
"confidenceLabel": "Pewność rozpoznania"
},
"empty": {
"noFacts": "Nic więcej do przejrzenia."
@@ -7897,8 +7901,8 @@
"dose": "Dawka"
},
"toast": {
"uploaded": "Dokument odczytany. Sprawdź dane poniżej.",
"uploadFailed": "Nie udało się odczytać dokumentu.",
"uploaded": "Dokument zapisany.",
"uploadFailed": "Nie udało się zapisać dokumentu.",
"confirmed": "Zatwierdzone dane zapisane.",
"confirmFailed": "Nie udało się zapisać Twoich decyzji.",
"saved": "Dane zaktualizowane.",
@@ -7907,7 +7911,11 @@
"extractFailed": "Nie udało się wyodrębnić faktów.",
"updated": "Dokument zaktualizowany.",
"updateFailed": "Nie udało się zaktualizować dokumentu.",
"discardFailed": "Nie udało się odrzucić dokumentu."
"discardFailed": "Nie udało się odrzucić dokumentu.",
"uploadTooLarge": "Plik jest za duży (maks. 12 MB).",
"uploadFileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
"uploadRateLimited": "Zbyt wiele przesłań. Spróbuj ponownie później.",
"uploadInvalidMetadata": "Sprawdź tytuł, kategorię lub datę i spróbuj ponownie."
},
"toolbar": {
"searchLabel": "Szukaj",
@@ -7951,6 +7959,9 @@
"note": {
"providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty.",
"alreadyConfirmed": "Ten dokument został już sprawdzony, więc nie można go ponownie wyodrębnić."
},
"committed": {
"title": "Zatwierdzone z tego dokumentu"
}
},
"selfDescription": {
@@ -128,6 +128,8 @@ describe("<InboundDocumentsView> library", () => {
expect(html).toContain("Load more");
// A11Y-M2: the row's aria-controls points at the detail region id.
expect(html).toContain('aria-controls="documents-detail-d1"');
// QoL: accepted formats + size ceiling are shown by the file field.
expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB");
});
it("disables the upload button and shows a hint until a file is chosen", () => {
@@ -287,6 +289,41 @@ describe("<DocumentDetail>", () => {
expect(html).toContain("Imaging");
});
it("shows the committed facts summary once approved, not the empty note", () => {
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }),
);
},
);
// M6: provenance stays visible — the approved facts are listed read-only…
expect(html).toContain('data-slot="documents-committed"');
expect(html).toContain("Approved from this document");
expect(html).toContain("LDL Cholesterol");
// …instead of stranding the user on "Nothing left to review."
expect(html).not.toContain("Nothing left to review.");
});
it("explains why a low-confidence fact can't be approved yet", () => {
const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true };
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ facts: [lowConf] }),
);
},
);
// M7: the disabled Approve is tied to a plain-language reason.
expect(html).toContain("Edit this fact to approve it.");
expect(html).toContain("Needs review");
});
it("offers a confirm step before discarding", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
@@ -4,7 +4,9 @@ import { ApiError } from "@/lib/api/api-fetch";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildDocumentListSearch,
classifyUploadError,
documentDateKey,
formatDateGroupLabel,
groupDocumentsByDate,
isAlreadyConfirmedError,
isProviderUnsupportedError,
@@ -126,13 +128,20 @@ describe("isProviderUnsupportedError", () => {
});
describe("isAlreadyConfirmedError", () => {
it("matches the extract 422 already-confirmed signal", () => {
const err = new ApiError("already confirmed", 422, {
it("matches the extract 409 partly-confirmed signal", () => {
const err = new ApiError("already confirmed", 409, {
errorCode: "documents.inbound.alreadyPartlyConfirmed",
});
expect(isAlreadyConfirmedError(err)).toBe(true);
});
it("matches the extract 422 fully-confirmed signal", () => {
const err = new ApiError("already confirmed", 422, {
errorCode: "documents.inbound.alreadyConfirmed",
});
expect(isAlreadyConfirmedError(err)).toBe(true);
});
it("rejects other API errors and non-errors", () => {
expect(
isAlreadyConfirmedError(
@@ -145,3 +154,50 @@ describe("isAlreadyConfirmedError", () => {
expect(isAlreadyConfirmedError(null)).toBe(false);
});
});
describe("classifyUploadError", () => {
it("maps each upload error code to its kind", () => {
const code = (c: string) =>
classifyUploadError(new ApiError("x", 400, { errorCode: c }));
expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge");
expect(code("documents.inbound.fileType")).toBe("fileType");
expect(code("documents.inbound.rateLimited")).toBe("rateLimited");
expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata");
});
it("falls back to the HTTP status when the body carries no error code", () => {
expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge");
expect(classifyUploadError(new ApiError("wrong type", 415))).toBe(
"fileType",
);
expect(classifyUploadError(new ApiError("slow down", 429))).toBe(
"rateLimited",
);
});
it("returns generic for unknown errors and non-API errors", () => {
expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic");
expect(classifyUploadError(new Error("network"))).toBe("generic");
expect(classifyUploadError(null)).toBe("generic");
});
});
describe("formatDateGroupLabel", () => {
it("delegates the calendar day to the supplied app formatter", () => {
const seen: string[] = [];
const formatDate = (value: string) => {
seen.push(value);
return "FORMATTED";
};
expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED");
// Handed over as a noon-UTC instant so the day never shifts under the tz.
expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]);
});
it("returns a malformed key unchanged without calling the formatter", () => {
const formatDate = () => {
throw new Error("should not be called");
};
expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date");
});
});
@@ -67,7 +67,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentListParams } from "@/lib/query-keys/documents";
import {
@@ -84,10 +84,12 @@ import {
} from "@/lib/validations/inbound-documents";
import {
buildDocumentListSearch,
classifyUploadError,
formatDateGroupLabel,
groupDocumentsByDate,
isAlreadyConfirmedError,
isProviderUnsupportedError,
MAX_UPLOAD_BYTES,
} from "./library-utils";
type ListResponse = {
@@ -97,7 +99,8 @@ type ListResponse = {
type Decision = "approve" | "reject";
export function InboundDocumentsView() {
const { t, locale } = useTranslations();
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
@@ -155,6 +158,17 @@ export function InboundDocumentsView() {
filters.q || filters.kind || filters.from || filters.to,
);
// Reset search + every filter to the default view. Used by the no-results
// "Clear filters" affordance and after an upload (so the just-stored document
// is in the list the detail panel opens against).
const clearFilters = () => {
setQ("");
setDebouncedQ("");
setKindFilter("");
setFromDate("");
setToDate("");
};
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
@@ -170,6 +184,14 @@ export function InboundDocumentsView() {
},
onSuccess: (doc) => {
toast.success(t("documents.toast.uploaded"));
// Drop any active filter / search / sort so the just-stored document is
// in the list — otherwise selecting it opens a detail panel for a row the
// current filter excludes, and the user lands on a dead end. A fresh
// upload files under today, so the default documentDate-desc view shows it
// at the top.
clearFilters();
setSort("documentDate");
setOrder("desc");
void invalidateKeys(queryClient, [queryKeys.documents()]);
setSelectedId(doc.id);
setUploadTitle("");
@@ -177,7 +199,24 @@ export function InboundDocumentsView() {
setHasFile(false);
if (fileRef.current) fileRef.current.value = "";
},
onError: () => toast.error(t("documents.toast.uploadFailed")),
onError: (error) => {
switch (classifyUploadError(error)) {
case "tooLarge":
toast.error(t("documents.toast.uploadTooLarge"));
break;
case "fileType":
toast.error(t("documents.toast.uploadFileType"));
break;
case "rateLimited":
toast.error(t("documents.toast.uploadRateLimited"));
break;
case "invalidMetadata":
toast.error(t("documents.toast.uploadInvalidMetadata"));
break;
default:
toast.error(t("documents.toast.uploadFailed"));
}
},
});
return (
@@ -201,6 +240,27 @@ export function InboundDocumentsView() {
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* The one required field goes first and full-width, with the
accepted formats + size ceiling beside it (so the limit is known
before an upload fails). */}
<div className="flex flex-col gap-1 sm:col-span-2">
<Label htmlFor="doc-file">
{t("documents.upload.fileLabel")}
</Label>
<Input
id="doc-file"
ref={fileRef}
type="file"
required
accept="image/jpeg,image/png,image/webp,application/pdf"
aria-describedby="doc-file-hint"
disabled={upload.isPending}
onChange={(e) => setHasFile(Boolean(e.target.files?.length))}
/>
<p id="doc-file-hint" className="text-muted-foreground text-xs">
{t("documents.upload.accepts")}
</p>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-title">
{t("documents.upload.titleLabel")}
@@ -244,25 +304,19 @@ export function InboundDocumentsView() {
disabled={upload.isPending}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-file">
{t("documents.upload.fileLabel")}
</Label>
<Input
id="doc-file"
ref={fileRef}
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 className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
const file = fileRef.current?.files?.[0];
if (file) upload.mutate(file);
if (!file) return;
// Reject an oversized file before it is sent, so the user gets
// the size message immediately instead of after a full upload.
if (file.size > MAX_UPLOAD_BYTES) {
toast.error(t("documents.toast.uploadTooLarge"));
return;
}
upload.mutate(file);
}}
disabled={upload.isPending || !hasFile}
>
@@ -289,10 +343,10 @@ export function InboundDocumentsView() {
{/* Toolbar — search / category filter / sort. */}
<div
className="flex flex-col gap-2 sm:flex-row sm:items-end"
className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-end"
data-slot="documents-toolbar"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Label htmlFor="doc-search">
{t("documents.toolbar.searchLabel")}
</Label>
@@ -394,11 +448,23 @@ export function InboundDocumentsView() {
))}
</div>
) : documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{isFiltered
? t("documents.list.noResults")
: t("documents.list.empty")}
</p>
isFiltered ? (
<div
className="flex flex-col items-start gap-2"
data-slot="documents-no-results"
>
<p className="text-muted-foreground text-sm">
{t("documents.list.noResults")}
</p>
<Button variant="outline" size="sm" onClick={clearFilters}>
{t("documents.list.clearFilters")}
</Button>
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("documents.list.empty")}
</p>
)
) : (
<>
{groups.map((group) => (
@@ -407,7 +473,7 @@ export function InboundDocumentsView() {
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
data-slot="documents-date-group"
>
{formatDateGroupLabel(group.key, locale)}
{formatDateGroupLabel(group.key, format.date)}
</h2>
{group.documents.map((doc) => {
const expanded = selectedId === doc.id;
@@ -518,6 +584,7 @@ export function DocumentDetail({
regionId?: string;
}) {
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const [editOpen, setEditOpen] = useState(false);
@@ -593,6 +660,10 @@ export function DocumentDetail({
const doc = detail.data;
const pending = doc.facts.filter((f) => f.status === "PENDING");
// Facts already approved from this document and pushed to the structured
// stores — surfaced read-only below so the document ↔ committed-record link
// stays visible after review (instead of the panel going blank).
const approved = doc.facts.filter((f) => f.status === "APPROVED");
const decidedCount = Object.keys(decisions).length;
// Mirror the backend's hard guard: extraction is gone once the document is
// confirmed OR any fact on it has already been approved (re-extracting would
@@ -732,13 +803,36 @@ export function DocumentDetail({
: t("documents.review.confirm")}
</Button>
</>
) : (
) : approved.length === 0 ? (
<p className="text-muted-foreground text-sm">
{doc.factCount > 0
? t("documents.empty.noFacts")
: t("documents.detail.notExtracted")}
</p>
)}
) : null}
{approved.length > 0 ? (
<div className="flex flex-col gap-2" data-slot="documents-committed">
<Separator />
<p className="text-muted-foreground text-sm">
{t("documents.committed.title")}
</p>
<ul className="flex flex-col gap-1">
{approved.map((fact) => {
const when = factDate(fact);
return (
<li
key={fact.id}
className="text-muted-foreground truncate text-sm"
>
{factSummary(fact)}
{when ? ` · ${format.date(`${when}T12:00:00.000Z`)}` : ""}
</li>
);
})}
</ul>
</div>
) : null}
</CardContent>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
@@ -890,6 +984,7 @@ function FactCard({
onDecision: (d: Decision | null) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
@@ -912,7 +1007,7 @@ function FactCard({
<div className="rounded-md border p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{t(`documents.factType.${fact.factType}`)}
</Badge>
@@ -927,8 +1022,14 @@ function FactCard({
{t("documents.review.needsReview")}
</Badge>
) : null}
<span className="text-muted-foreground text-xs">
{Math.round(fact.confidence * 100)}%
<span
className="text-muted-foreground text-xs"
title={t("documents.review.confidenceLabel")}
>
<span className="sr-only">
{t("documents.review.confidenceLabel")}:{" "}
</span>
{format.percent(fact.confidence)}
</span>
</div>
<p className="mt-1 truncate text-sm font-medium">{summary}</p>
@@ -937,6 +1038,13 @@ function FactCard({
“{fact.provenance.sourceText}”
</p>
) : null}
{fact.needsReview ? (
// Ties the disabled Approve button to the "Needs review" badge: a
// low-confidence fact must be edited before it can be approved.
<p className="text-muted-foreground mt-1 text-xs">
{t("documents.review.needsReviewHint")}
</p>
) : null}
</div>
<div className="flex shrink-0 gap-1">
<Button
@@ -944,6 +1052,11 @@ function FactCard({
size="sm"
className="min-h-11 sm:min-h-9"
disabled={fact.needsReview}
title={
fact.needsReview
? t("documents.review.needsReviewHint")
: undefined
}
onClick={() =>
onDecision(decision === "approve" ? null : "approve")
}
@@ -1148,3 +1261,14 @@ function factSummary(fact: ExtractedFactDto): string {
const d = fact.data as ConditionFactData;
return d.label;
}
/** The effective / onset date a staged fact carries, if any (YYYY-MM-DD). */
function factDate(fact: ExtractedFactDto): string | null {
if (fact.factType === "CONDITION") {
return (fact.data as ConditionFactData).onsetDate;
}
if (fact.factType === "OBSERVATION") {
return (fact.data as ObservationFactData).effectiveDate;
}
return (fact.data as MedicationStatementFactData).effectiveDate;
}
+61 -16
View File
@@ -83,27 +83,72 @@ 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.
* True when an error is the extract route's "already confirmed" signal. Two
* shapes reach here:
* - 409 `alreadyPartlyConfirmed` — some facts approved, the rest still pending;
* - 422 `alreadyConfirmed` — the whole document is CONFIRMED (a cross-tab race
* can fire Extract against a document a second tab just finished confirming).
* Re-extraction is refused server-side in both cases; the UI surfaces a calm
* inline note rather than a hard error toast, and hides the Extract control to
* match.
*/
export function isAlreadyConfirmedError(error: unknown): boolean {
if (!(error instanceof ApiError)) return false;
const code = error.meta?.errorCode;
return (
error instanceof ApiError &&
error.meta?.errorCode === "documents.inbound.alreadyPartlyConfirmed"
code === "documents.inbound.alreadyPartlyConfirmed" ||
code === "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`);
if (Number.isNaN(date.getTime())) return key;
try {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
date,
);
} catch {
return key;
/**
* Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an
* oversized file is rejected before it is sent, instead of after a full upload
* round-trips and returns 413.
*/
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
/**
* The class of an upload (`POST /api/documents/inbound`) failure. The route
* reports precise, actionable codes the generic toast would throw away; this
* maps them to a stable kind so the view can show the right message and keep
* every `t()` key a literal call site.
*/
export type UploadErrorKind =
"tooLarge" | "fileType" | "rateLimited" | "invalidMetadata" | "generic";
/** Classify an upload failure by the route's `errorCode`, then HTTP status. */
export function classifyUploadError(error: unknown): UploadErrorKind {
if (!(error instanceof ApiError)) return "generic";
switch (error.meta?.errorCode) {
case "documents.inbound.fileTooLarge":
return "tooLarge";
case "documents.inbound.fileType":
return "fileType";
case "documents.inbound.rateLimited":
return "rateLimited";
case "documents.inbound.invalidMetadata":
return "invalidMetadata";
}
// A reverse proxy can strip the JSON body (and its `meta`) on some statuses;
// fall back to the HTTP code so size / type / rate-limit still classify.
if (error.status === 413) return "tooLarge";
if (error.status === 415) return "fileType";
if (error.status === 429) return "rateLimited";
return "generic";
}
/**
* Format a YYYY-MM-DD group key into a date label via the app's own date
* formatter (`useFormatters().date`), so the group headers follow the user's
* date-format preference (numeric) like the rest of the app instead of a textual
* `dateStyle: "medium"` month. The key is handed over as a noon-UTC instant so
* the calendar day never shifts under the display timezone.
*/
export function formatDateGroupLabel(
key: string,
formatDate: (value: string) => string,
): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return key;
return formatDate(`${key}T12:00:00.000Z`);
}