mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
4702546cdb
commit
e7683c9f54
10 files changed
+423
-95
No files matched your search
+23
-12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
Reference in new issue
Block a user