Merge v1.25.1: documents library UI, metadata editing, optional extraction, rename to Documents

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 23:26:42 +02:00
commit 84543ad540
11 files changed
+1606 -284

No files matched your search

+93 -24
View File
@@ -4022,15 +4022,33 @@
"subSectionsNav": "Einstellungs-Unterbereiche"
},
"groups": {
"account": { "title": "Konto" },
"tracking": { "title": "Tracking" },
"display": { "title": "Darstellung" },
"integrations": { "title": "Integrationen" },
"notifications": { "title": "Benachrichtigungen" },
"ai": { "title": "KI & Coach" },
"access": { "title": "API & Zugriff" },
"data": { "title": "Daten & Datenschutz" },
"about": { "title": "Über" }
"account": {
"title": "Konto"
},
"tracking": {
"title": "Tracking"
},
"display": {
"title": "Darstellung"
},
"integrations": {
"title": "Integrationen"
},
"notifications": {
"title": "Benachrichtigungen"
},
"ai": {
"title": "KI & Coach"
},
"access": {
"title": "API & Zugriff"
},
"data": {
"title": "Daten & Datenschutz"
},
"about": {
"title": "Über"
}
},
"sections": {
"account": {
@@ -7335,8 +7353,8 @@
"description": "Tägliches Wetter und Tageslicht für deinen Ort einbeziehen, um Zusammenhänge mit Stimmung, Schlaf und Vitalwerten zu erkennen. Standardmäßig aus."
},
"inboundDocuments": {
"label": "Eingehende Dokumente",
"description": "Lade einen Arztbericht oder Entlassbrief hoch; prüfe die erkannten Angaben, bevor sie gespeichert werden. Standardmäßig aus."
"label": "Dokumente",
"description": "Speichere deine medizinischen Dokumente; optional ihre Fakten extrahieren und prüfen, bevor sie gespeichert werden. Standardmäßig aus."
},
"mentalHealth": {
"label": "Seelisches Wohlbefinden",
@@ -7813,34 +7831,46 @@
}
},
"documents": {
"title": "Eingehende Dokumente",
"subtitle": "Lade ein klinisches Dokument hoch und prüfe die ausgelesenen Angaben.",
"title": "Dokumente",
"subtitle": "Speichere deine medizinischen Dokumente und prüfe, was darin steht.",
"upload": {
"label": "Dokument hochladen",
"kindLabel": "Dokumenttyp",
"label": "Dokument hinzufügen",
"kindLabel": "Kategorie",
"button": "Hochladen",
"uploading": "Wird gelesen …",
"fileLabel": "Dokumentdatei"
"uploading": "Wird hochgeladen…",
"fileLabel": "Datei",
"titleLabel": "Titel (optional)",
"titlePlaceholder": "z. B. Kardiologie-Bericht",
"dateLabel": "Dokumentdatum (optional)"
},
"kind": {
"DOCTOR_REPORT": "Arztbericht",
"DISCHARGE_LETTER": "Entlassbrief",
"OTHER": "Sonstiges"
"DISCHARGE_LETTER": "Entlassungsbrief",
"OTHER": "Sonstiges",
"LAB_RESULT": "Laborbefund",
"IMAGING": "Bildgebung",
"PRESCRIPTION": "Rezept",
"INSURANCE": "Versicherung",
"VACCINATION": "Impfung"
},
"list": {
"empty": "Noch keine Dokumente."
"empty": "Noch keine Dokumente.",
"noResults": "Keine Dokumente passen zu deinen Filtern.",
"loadMore": "Mehr laden",
"loadingMore": "Wird geladen…",
"pending": "{count} zu prüfen",
"facts": "{count} Fakten"
},
"status": {
"EXTRACTING": "Wird gelesen",
"EXTRACTED": "Bereit zur Prüfung",
"FAILED": "Konnte nicht gelesen werden",
"CONFIRMED": "Geprüft",
"DISCARDED": "Verworfen"
"DISCARDED": "Verworfen",
"STORED": "Gespeichert"
},
"review": {
"title": "Erkannte Angaben prüfen",
"discard": "Verwerfen",
"viewOriginal": "Original ansehen",
"confirm": "Bestätigte Angaben speichern",
"confirming": "Wird gespeichert …",
"needsReview": "Prüfung nötig",
@@ -7871,7 +7901,46 @@
"confirmed": "Bestätigte Angaben gespeichert.",
"confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.",
"saved": "Angabe aktualisiert.",
"discarded": "Dokument verworfen."
"discarded": "Dokument verworfen.",
"extracted": "Fakten extrahiert. Prüfe sie unten.",
"extractFailed": "Fakten konnten nicht extrahiert werden.",
"updated": "Dokument aktualisiert.",
"updateFailed": "Dokument konnte nicht aktualisiert werden."
},
"toolbar": {
"searchLabel": "Suche",
"searchPlaceholder": "Titel oder Dateiname",
"kindFilterLabel": "Kategorie",
"allKinds": "Alle Kategorien",
"sortLabel": "Sortieren nach",
"sort": {
"documentDate": "Dokumentdatum",
"createdAt": "Hinzugefügt am",
"title": "Titel"
},
"orderAsc": "Aufsteigend",
"orderDesc": "Absteigend"
},
"detail": {
"viewOriginal": "Original ansehen",
"edit": "Bearbeiten",
"extract": "Fakten extrahieren",
"extracting": "Wird extrahiert…",
"discard": "Verwerfen",
"notExtracted": "Noch keine Fakten extrahiert. Nutze „Fakten extrahieren“, um dieses Dokument zu lesen."
},
"edit": {
"title": "Dokument bearbeiten",
"subtitle": "Umbenennen, neu kategorisieren oder das Dokumentdatum setzen.",
"titleLabel": "Titel",
"kindLabel": "Kategorie",
"dateLabel": "Dokumentdatum",
"save": "Speichern",
"saving": "Wird gespeichert…",
"cancel": "Abbrechen"
},
"note": {
"providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Fakten automatisch zu extrahieren."
}
},
"selfDescription": {
+92 -23
View File
@@ -4022,15 +4022,33 @@
"subSectionsNav": "Settings sub-sections"
},
"groups": {
"account": { "title": "Account" },
"tracking": { "title": "Tracking" },
"display": { "title": "Display" },
"integrations": { "title": "Integrations" },
"notifications": { "title": "Notifications" },
"ai": { "title": "AI & Coach" },
"access": { "title": "API & Access" },
"data": { "title": "Data & Privacy" },
"about": { "title": "About" }
"account": {
"title": "Account"
},
"tracking": {
"title": "Tracking"
},
"display": {
"title": "Display"
},
"integrations": {
"title": "Integrations"
},
"notifications": {
"title": "Notifications"
},
"ai": {
"title": "AI & Coach"
},
"access": {
"title": "API & Access"
},
"data": {
"title": "Data & Privacy"
},
"about": {
"title": "About"
}
},
"sections": {
"account": {
@@ -7335,8 +7353,8 @@
"description": "Pull in daily weather and daylight for your location to correlate with mood, sleep, and vitals. Off by default."
},
"inboundDocuments": {
"label": "Inbound documents",
"description": "Upload a doctor report or discharge letter; review the extracted facts before they're saved. Off by default."
"label": "Documents",
"description": "Store your clinical documents; optionally extract and review their facts before they're saved. Off by default."
},
"mentalHealth": {
"label": "Mental wellbeing",
@@ -7813,34 +7831,46 @@
}
},
"documents": {
"title": "Inbound documents",
"subtitle": "Upload a clinical document and review the extracted details.",
"title": "Documents",
"subtitle": "Store your clinical documents and review what they say.",
"upload": {
"label": "Upload a document",
"kindLabel": "Document type",
"label": "Add a document",
"kindLabel": "Category",
"button": "Upload",
"uploading": "Reading…",
"fileLabel": "Document file"
"uploading": "Uploading…",
"fileLabel": "File",
"titleLabel": "Title (optional)",
"titlePlaceholder": "e.g. Cardiology report",
"dateLabel": "Document date (optional)"
},
"kind": {
"DOCTOR_REPORT": "Doctor report",
"DISCHARGE_LETTER": "Discharge letter",
"OTHER": "Other"
"OTHER": "Other",
"LAB_RESULT": "Lab result",
"IMAGING": "Imaging",
"PRESCRIPTION": "Prescription",
"INSURANCE": "Insurance",
"VACCINATION": "Vaccination"
},
"list": {
"empty": "No documents yet."
"empty": "No documents yet.",
"noResults": "No documents match your filters.",
"loadMore": "Load more",
"loadingMore": "Loading…",
"pending": "{count} to review",
"facts": "{count} facts"
},
"status": {
"EXTRACTING": "Reading",
"EXTRACTED": "Ready to review",
"FAILED": "Couldn't read",
"CONFIRMED": "Reviewed",
"DISCARDED": "Discarded"
"DISCARDED": "Discarded",
"STORED": "Stored"
},
"review": {
"title": "Review extracted facts",
"discard": "Discard",
"viewOriginal": "View original",
"confirm": "Save approved facts",
"confirming": "Saving…",
"needsReview": "Needs review",
@@ -7871,7 +7901,46 @@
"confirmed": "Approved facts saved.",
"confirmFailed": "Couldn't save your decisions.",
"saved": "Fact updated.",
"discarded": "Document discarded."
"discarded": "Document discarded.",
"extracted": "Facts extracted. Review them below.",
"extractFailed": "Couldn't extract facts.",
"updated": "Document updated.",
"updateFailed": "Couldn't update the document."
},
"toolbar": {
"searchLabel": "Search",
"searchPlaceholder": "Title or filename",
"kindFilterLabel": "Category",
"allKinds": "All categories",
"sortLabel": "Sort by",
"sort": {
"documentDate": "Document date",
"createdAt": "Date added",
"title": "Title"
},
"orderAsc": "Ascending",
"orderDesc": "Descending"
},
"detail": {
"viewOriginal": "View original",
"edit": "Edit",
"extract": "Extract facts",
"extracting": "Extracting…",
"discard": "Discard",
"notExtracted": "No facts extracted yet. Use Extract facts to read this document."
},
"edit": {
"title": "Edit document",
"subtitle": "Rename, recategorise, or set the document date.",
"titleLabel": "Title",
"kindLabel": "Category",
"dateLabel": "Document date",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel"
},
"note": {
"providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically."
}
},
"selfDescription": {
+93 -24
View File
@@ -4022,15 +4022,33 @@
"subSectionsNav": "Subsecciones de ajustes"
},
"groups": {
"account": { "title": "Cuenta" },
"tracking": { "title": "Seguimiento" },
"display": { "title": "Visualización" },
"integrations": { "title": "Integraciones" },
"notifications": { "title": "Notificaciones" },
"ai": { "title": "IA y Coach" },
"access": { "title": "API y acceso" },
"data": { "title": "Datos y privacidad" },
"about": { "title": "Acerca de" }
"account": {
"title": "Cuenta"
},
"tracking": {
"title": "Seguimiento"
},
"display": {
"title": "Visualización"
},
"integrations": {
"title": "Integraciones"
},
"notifications": {
"title": "Notificaciones"
},
"ai": {
"title": "IA y Coach"
},
"access": {
"title": "API y acceso"
},
"data": {
"title": "Datos y privacidad"
},
"about": {
"title": "Acerca de"
}
},
"sections": {
"account": {
@@ -7335,8 +7353,8 @@
"description": "Incorpora el tiempo y las horas de luz diarias de tu ubicación para correlacionarlos con el ánimo, el sueño y las constantes. Desactivado por defecto."
},
"inboundDocuments": {
"label": "Documentos entrantes",
"description": "Sube un informe médico o un alta; revisa los datos extraídos antes de guardarlos. Desactivado de forma predeterminada."
"label": "Documentos",
"description": "Guarda tus documentos clínicos; opcionalmente extrae y revisa sus datos antes de guardarlos. Desactivado por defecto."
},
"mentalHealth": {
"label": "Bienestar mental",
@@ -7813,34 +7831,46 @@
}
},
"documents": {
"title": "Documentos entrantes",
"subtitle": "Sube un documento clínico y revisa los datos extraídos.",
"title": "Documentos",
"subtitle": "Guarda tus documentos clínicos y revisa lo que dicen.",
"upload": {
"label": "Subir un documento",
"kindLabel": "Tipo de documento",
"label": "Añadir un documento",
"kindLabel": "Categoría",
"button": "Subir",
"uploading": "Leyendo…",
"fileLabel": "Archivo del documento"
"uploading": "Subiendo…",
"fileLabel": "Archivo",
"titleLabel": "Título (opcional)",
"titlePlaceholder": "p. ej. Informe de cardiología",
"dateLabel": "Fecha del documento (opcional)"
},
"kind": {
"DOCTOR_REPORT": "Informe médico",
"DISCHARGE_LETTER": "Alta hospitalaria",
"OTHER": "Otro"
"DISCHARGE_LETTER": "Informe de alta",
"OTHER": "Otro",
"LAB_RESULT": "Resultado de laboratorio",
"IMAGING": "Imagen",
"PRESCRIPTION": "Receta",
"INSURANCE": "Seguro",
"VACCINATION": "Vacunación"
},
"list": {
"empty": "Aún no hay documentos."
"empty": "Aún no hay documentos.",
"noResults": "Ningún documento coincide con tus filtros.",
"loadMore": "Cargar más",
"loadingMore": "Cargando…",
"pending": "{count} por revisar",
"facts": "{count} datos"
},
"status": {
"EXTRACTING": "Leyendo",
"EXTRACTED": "Listo para revisar",
"FAILED": "No se pudo leer",
"CONFIRMED": "Revisado",
"DISCARDED": "Descartado"
"DISCARDED": "Descartado",
"STORED": "Guardado"
},
"review": {
"title": "Revisar datos extraídos",
"discard": "Descartar",
"viewOriginal": "Ver original",
"confirm": "Guardar datos aprobados",
"confirming": "Guardando…",
"needsReview": "Requiere revisión",
@@ -7871,7 +7901,46 @@
"confirmed": "Datos aprobados guardados.",
"confirmFailed": "No se pudieron guardar tus decisiones.",
"saved": "Dato actualizado.",
"discarded": "Documento descartado."
"discarded": "Documento descartado.",
"extracted": "Datos extraídos. Revísalos abajo.",
"extractFailed": "No se pudieron extraer los datos.",
"updated": "Documento actualizado.",
"updateFailed": "No se pudo actualizar el documento."
},
"toolbar": {
"searchLabel": "Buscar",
"searchPlaceholder": "Título o nombre de archivo",
"kindFilterLabel": "Categoría",
"allKinds": "Todas las categorías",
"sortLabel": "Ordenar por",
"sort": {
"documentDate": "Fecha del documento",
"createdAt": "Fecha de adición",
"title": "Título"
},
"orderAsc": "Ascendente",
"orderDesc": "Descendente"
},
"detail": {
"viewOriginal": "Ver original",
"edit": "Editar",
"extract": "Extraer datos",
"extracting": "Extrayendo…",
"discard": "Descartar",
"notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento."
},
"edit": {
"title": "Editar documento",
"subtitle": "Renombra, recategoriza o fija la fecha del documento.",
"titleLabel": "Título",
"kindLabel": "Categoría",
"dateLabel": "Fecha del documento",
"save": "Guardar",
"saving": "Guardando…",
"cancel": "Cancelar"
},
"note": {
"providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente."
}
},
"selfDescription": {
+93 -24
View File
@@ -4022,15 +4022,33 @@
"subSectionsNav": "Sous-sections des réglages"
},
"groups": {
"account": { "title": "Compte" },
"tracking": { "title": "Suivi" },
"display": { "title": "Affichage" },
"integrations": { "title": "Intégrations" },
"notifications": { "title": "Notifications" },
"ai": { "title": "IA et coach" },
"access": { "title": "API et accès" },
"data": { "title": "Données et confidentialité" },
"about": { "title": "À propos" }
"account": {
"title": "Compte"
},
"tracking": {
"title": "Suivi"
},
"display": {
"title": "Affichage"
},
"integrations": {
"title": "Intégrations"
},
"notifications": {
"title": "Notifications"
},
"ai": {
"title": "IA et coach"
},
"access": {
"title": "API et accès"
},
"data": {
"title": "Données et confidentialité"
},
"about": {
"title": "À propos"
}
},
"sections": {
"account": {
@@ -7335,8 +7353,8 @@
"description": "Intégrez la météo et la durée du jour quotidiennes de votre lieu pour les corréler à l'humeur, au sommeil et aux constantes. Désactivé par défaut."
},
"inboundDocuments": {
"label": "Documents entrants",
"description": "Importez un compte rendu médical ou une lettre de sortie ; vérifiez les données extraites avant de les enregistrer. Désactivé par défaut."
"label": "Documents",
"description": "Conservez vos documents médicaux ; extrayez et vérifiez éventuellement leurs éléments avant l'enregistrement. Désactivé par défaut."
},
"mentalHealth": {
"label": "Bien-être mental",
@@ -7813,34 +7831,46 @@
}
},
"documents": {
"title": "Documents entrants",
"subtitle": "Importez un document clinique et vérifiez les données extraites.",
"title": "Documents",
"subtitle": "Conservez vos documents médicaux et vérifiez ce qu'ils contiennent.",
"upload": {
"label": "Importer un document",
"kindLabel": "Type de document",
"button": "Importer",
"uploading": "Lecture…",
"fileLabel": "Fichier du document"
"label": "Ajouter un document",
"kindLabel": "Catégorie",
"button": "Téléverser",
"uploading": "Téléversement…",
"fileLabel": "Fichier",
"titleLabel": "Titre (facultatif)",
"titlePlaceholder": "p. ex. Compte rendu de cardiologie",
"dateLabel": "Date du document (facultatif)"
},
"kind": {
"DOCTOR_REPORT": "Compte rendu médical",
"DISCHARGE_LETTER": "Lettre de sortie",
"OTHER": "Autre"
"OTHER": "Autre",
"LAB_RESULT": "Résultat de laboratoire",
"IMAGING": "Imagerie",
"PRESCRIPTION": "Ordonnance",
"INSURANCE": "Assurance",
"VACCINATION": "Vaccination"
},
"list": {
"empty": "Aucun document pour l'instant."
"empty": "Aucun document pour l'instant.",
"noResults": "Aucun document ne correspond à vos filtres.",
"loadMore": "Charger plus",
"loadingMore": "Chargement…",
"pending": "{count} à vérifier",
"facts": "{count} éléments"
},
"status": {
"EXTRACTING": "Lecture",
"EXTRACTED": "Prêt à vérifier",
"FAILED": "Lecture impossible",
"CONFIRMED": "Vérifié",
"DISCARDED": "Écarté"
"DISCARDED": "Écarté",
"STORED": "Conservé"
},
"review": {
"title": "Vérifier les données extraites",
"discard": "Écarter",
"viewOriginal": "Voir l'original",
"confirm": "Enregistrer les données approuvées",
"confirming": "Enregistrement…",
"needsReview": "À vérifier",
@@ -7871,7 +7901,46 @@
"confirmed": "Données approuvées enregistrées.",
"confirmFailed": "Impossible d'enregistrer vos décisions.",
"saved": "Donnée mise à jour.",
"discarded": "Document écarté."
"discarded": "Document écarté.",
"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."
},
"toolbar": {
"searchLabel": "Rechercher",
"searchPlaceholder": "Titre ou nom de fichier",
"kindFilterLabel": "Catégorie",
"allKinds": "Toutes les catégories",
"sortLabel": "Trier par",
"sort": {
"documentDate": "Date du document",
"createdAt": "Date d'ajout",
"title": "Titre"
},
"orderAsc": "Croissant",
"orderDesc": "Décroissant"
},
"detail": {
"viewOriginal": "Voir l'original",
"edit": "Modifier",
"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."
},
"edit": {
"title": "Modifier le document",
"subtitle": "Renommez, recatégorisez ou définissez la date du document.",
"titleLabel": "Titre",
"kindLabel": "Catégorie",
"dateLabel": "Date du document",
"save": "Enregistrer",
"saving": "Enregistrement…",
"cancel": "Annuler"
},
"note": {
"providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement."
}
},
"selfDescription": {
+93 -24
View File
@@ -4022,15 +4022,33 @@
"subSectionsNav": "Sottosezioni delle impostazioni"
},
"groups": {
"account": { "title": "Account" },
"tracking": { "title": "Monitoraggio" },
"display": { "title": "Visualizzazione" },
"integrations": { "title": "Integrazioni" },
"notifications": { "title": "Notifiche" },
"ai": { "title": "IA e coach" },
"access": { "title": "API e accesso" },
"data": { "title": "Dati e privacy" },
"about": { "title": "Informazioni" }
"account": {
"title": "Account"
},
"tracking": {
"title": "Monitoraggio"
},
"display": {
"title": "Visualizzazione"
},
"integrations": {
"title": "Integrazioni"
},
"notifications": {
"title": "Notifiche"
},
"ai": {
"title": "IA e coach"
},
"access": {
"title": "API e accesso"
},
"data": {
"title": "Dati e privacy"
},
"about": {
"title": "Informazioni"
}
},
"sections": {
"account": {
@@ -7335,8 +7353,8 @@
"description": "Integra il meteo e le ore di luce giornaliere della tua posizione per correlarli con umore, sonno e parametri vitali. Disattivato di default."
},
"inboundDocuments": {
"label": "Documenti in entrata",
"description": "Carica un referto medico o una lettera di dimissione; rivedi i dati estratti prima di salvarli. Disattivato per impostazione predefinita."
"label": "Documenti",
"description": "Conserva i tuoi documenti clinici; facoltativamente estrai e rivedi i loro dati prima del salvataggio. Disattivato di default."
},
"mentalHealth": {
"label": "Benessere mentale",
@@ -7813,34 +7831,46 @@
}
},
"documents": {
"title": "Documenti in entrata",
"subtitle": "Carica un documento clinico e controlla i dati estratti.",
"title": "Documenti",
"subtitle": "Conserva i tuoi documenti clinici e controlla cosa contengono.",
"upload": {
"label": "Carica un documento",
"kindLabel": "Tipo di documento",
"label": "Aggiungi un documento",
"kindLabel": "Categoria",
"button": "Carica",
"uploading": "Lettura…",
"fileLabel": "File del documento"
"uploading": "Caricamento…",
"fileLabel": "File",
"titleLabel": "Titolo (facoltativo)",
"titlePlaceholder": "es. Referto di cardiologia",
"dateLabel": "Data del documento (facoltativa)"
},
"kind": {
"DOCTOR_REPORT": "Referto medico",
"DISCHARGE_LETTER": "Lettera di dimissione",
"OTHER": "Altro"
"OTHER": "Altro",
"LAB_RESULT": "Risultato di laboratorio",
"IMAGING": "Diagnostica per immagini",
"PRESCRIPTION": "Prescrizione",
"INSURANCE": "Assicurazione",
"VACCINATION": "Vaccinazione"
},
"list": {
"empty": "Ancora nessun documento."
"empty": "Nessun documento ancora.",
"noResults": "Nessun documento corrisponde ai filtri.",
"loadMore": "Carica altri",
"loadingMore": "Caricamento…",
"pending": "{count} da rivedere",
"facts": "{count} dati"
},
"status": {
"EXTRACTING": "Lettura",
"EXTRACTED": "Pronto per la revisione",
"FAILED": "Impossibile leggere",
"FAILED": "Lettura non riuscita",
"CONFIRMED": "Rivisto",
"DISCARDED": "Scartato"
"DISCARDED": "Scartato",
"STORED": "Salvato"
},
"review": {
"title": "Rivedi i dati estratti",
"discard": "Scarta",
"viewOriginal": "Vedi originale",
"confirm": "Salva i dati approvati",
"confirming": "Salvataggio…",
"needsReview": "Da rivedere",
@@ -7871,7 +7901,46 @@
"confirmed": "Dati approvati salvati.",
"confirmFailed": "Impossibile salvare le tue scelte.",
"saved": "Dato aggiornato.",
"discarded": "Documento scartato."
"discarded": "Documento scartato.",
"extracted": "Dati estratti. Rivedili qui sotto.",
"extractFailed": "Impossibile estrarre i dati.",
"updated": "Documento aggiornato.",
"updateFailed": "Impossibile aggiornare il documento."
},
"toolbar": {
"searchLabel": "Cerca",
"searchPlaceholder": "Titolo o nome file",
"kindFilterLabel": "Categoria",
"allKinds": "Tutte le categorie",
"sortLabel": "Ordina per",
"sort": {
"documentDate": "Data del documento",
"createdAt": "Data di aggiunta",
"title": "Titolo"
},
"orderAsc": "Crescente",
"orderDesc": "Decrescente"
},
"detail": {
"viewOriginal": "Vedi originale",
"edit": "Modifica",
"extract": "Estrai dati",
"extracting": "Estrazione…",
"discard": "Scarta",
"notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento."
},
"edit": {
"title": "Modifica documento",
"subtitle": "Rinomina, ricategorizza o imposta la data del documento.",
"titleLabel": "Titolo",
"kindLabel": "Categoria",
"dateLabel": "Data del documento",
"save": "Salva",
"saving": "Salvataggio…",
"cancel": "Annulla"
},
"note": {
"providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente."
}
},
"selfDescription": {
+96 -27
View File
@@ -4022,15 +4022,33 @@
"subSectionsNav": "Podsekcje ustawień"
},
"groups": {
"account": { "title": "Konto" },
"tracking": { "title": "Śledzenie" },
"display": { "title": "Wyświetlanie" },
"integrations": { "title": "Integracje" },
"notifications": { "title": "Powiadomienia" },
"ai": { "title": "AI i Coach" },
"access": { "title": "API i dostęp" },
"data": { "title": "Dane i prywatność" },
"about": { "title": "O aplikacji" }
"account": {
"title": "Konto"
},
"tracking": {
"title": "Śledzenie"
},
"display": {
"title": "Wyświetlanie"
},
"integrations": {
"title": "Integracje"
},
"notifications": {
"title": "Powiadomienia"
},
"ai": {
"title": "AI i Coach"
},
"access": {
"title": "API i dostęp"
},
"data": {
"title": "Dane i prywatność"
},
"about": {
"title": "O aplikacji"
}
},
"sections": {
"account": {
@@ -7335,8 +7353,8 @@
"description": "Pobieraj dzienną pogodę i długość dnia dla swojej lokalizacji, aby powiązać je z nastrojem, snem i parametrami życiowymi. Domyślnie wyłączone."
},
"inboundDocuments": {
"label": "Dokumenty przychodzące",
"description": "Prześlij raport lekarski lub wypis; sprawdź wyodrębnione dane przed zapisaniem. Domyślnie wyłączone."
"label": "Dokumenty",
"description": "Przechowuj swoje dokumenty medyczne; opcjonalnie wyodrębniaj i przeglądaj ich fakty przed zapisaniem. Domyślnie wyłączone."
},
"mentalHealth": {
"label": "Dobrostan psychiczny",
@@ -7813,34 +7831,46 @@
}
},
"documents": {
"title": "Dokumenty przychodzące",
"subtitle": "Prześlij dokument kliniczny i sprawdź wyodrębnione dane.",
"title": "Dokumenty",
"subtitle": "Przechowuj swoje dokumenty medyczne i sprawdzaj, co zawierają.",
"upload": {
"label": "Prześlij dokument",
"kindLabel": "Typ dokumentu",
"label": "Dodaj dokument",
"kindLabel": "Kategoria",
"button": "Prześlij",
"uploading": "Odczytywanie…",
"fileLabel": "Plik dokumentu"
"uploading": "Przesyłanie…",
"fileLabel": "Plik",
"titleLabel": "Tytuł (opcjonalnie)",
"titlePlaceholder": "np. Raport kardiologiczny",
"dateLabel": "Data dokumentu (opcjonalnie)"
},
"kind": {
"DOCTOR_REPORT": "Raport lekarski",
"DISCHARGE_LETTER": "Wypis",
"OTHER": "Inny"
"DISCHARGE_LETTER": "Wypis ze szpitala",
"OTHER": "Inne",
"LAB_RESULT": "Wynik laboratoryjny",
"IMAGING": "Obrazowanie",
"PRESCRIPTION": "Recepta",
"INSURANCE": "Ubezpieczenie",
"VACCINATION": "Szczepienie"
},
"list": {
"empty": "Brak dokumentów."
"empty": "Brak dokumentów.",
"noResults": "Żaden dokument nie pasuje do filtrów.",
"loadMore": "Załaduj więcej",
"loadingMore": "Ładowanie…",
"pending": "{count} do przeglądu",
"facts": "{count} faktów"
},
"status": {
"EXTRACTING": "Odczytywanie",
"EXTRACTED": "Gotowe do przeglądu",
"EXTRACTING": "Odczyt",
"EXTRACTED": "Gotowy do przeglądu",
"FAILED": "Nie udało się odczytać",
"CONFIRMED": "Sprawdzone",
"DISCARDED": "Odrzucone"
"CONFIRMED": "Przejrzany",
"DISCARDED": "Odrzucony",
"STORED": "Zapisany"
},
"review": {
"title": "Przejrzyj wyodrębnione dane",
"discard": "Odrzuć",
"viewOriginal": "Zobacz oryginał",
"confirm": "Zapisz zatwierdzone dane",
"confirming": "Zapisywanie…",
"needsReview": "Wymaga przeglądu",
@@ -7871,7 +7901,46 @@
"confirmed": "Zatwierdzone dane zapisane.",
"confirmFailed": "Nie udało się zapisać Twoich decyzji.",
"saved": "Dane zaktualizowane.",
"discarded": "Dokument odrzucony."
"discarded": "Dokument odrzucony.",
"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."
},
"toolbar": {
"searchLabel": "Szukaj",
"searchPlaceholder": "Tytuł lub nazwa pliku",
"kindFilterLabel": "Kategoria",
"allKinds": "Wszystkie kategorie",
"sortLabel": "Sortuj według",
"sort": {
"documentDate": "Data dokumentu",
"createdAt": "Data dodania",
"title": "Tytuł"
},
"orderAsc": "Rosnąco",
"orderDesc": "Malejąco"
},
"detail": {
"viewOriginal": "Zobacz oryginał",
"edit": "Edytuj",
"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."
},
"edit": {
"title": "Edytuj dokument",
"subtitle": "Zmień nazwę, kategorię lub ustaw datę dokumentu.",
"titleLabel": "Tytuł",
"kindLabel": "Kategoria",
"dateLabel": "Data dokumentu",
"save": "Zapisz",
"saving": "Zapisywanie…",
"cancel": "Anuluj"
},
"note": {
"providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty."
}
},
"selfDescription": {
@@ -0,0 +1,235 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type {
ExtractedFactDto,
InboundDocumentDetailDto,
InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
/**
* v1.25 — the Documents library.
*
* The harness is SSR-only (node env, no jsdom): we seed the TanStack cache and
* assert the static markup. Interaction-driven paths (the exact request params,
* the provider-unsupported classification) are covered by the pure-helper unit
* tests in `library-utils.test.ts`; here we assert the rendered library, the
* detail/review surface, the edit form, and the inline provider note.
*/
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiFetch: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import {
DocumentDetail,
DocumentMetaEditForm,
InboundDocumentsView,
ProviderUnsupportedNote,
} from "../inbound-documents-view";
function doc(
id: string,
over: Partial<InboundDocumentDto> = {},
): InboundDocumentDto {
return {
id,
kind: "LAB_RESULT",
title: null,
filename: `${id}.pdf`,
mimeType: "application/pdf",
byteSize: 1000,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2026-06-20",
errorReason: null,
factCount: 0,
pendingCount: 0,
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T08:00:00.000Z",
...over,
};
}
function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
seed?.(queryClient);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InboundDocumentsView> library", () => {
it("renders the grouped list with titles, badges, and load-more", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{
pages: [
{
documents: [
doc("d1", {
title: "Cardiology report",
documentDate: "2026-06-20",
pendingCount: 2,
factCount: 3,
}),
doc("d2", {
title: "Blood panel",
documentDate: "2026-06-20",
}),
],
nextCursor: "cursor-next",
},
],
pageParams: [null],
},
);
});
// Library + toolbar shells render.
expect(html).toContain('data-slot="documents-library"');
expect(html).toContain('data-slot="documents-toolbar"');
// Date group header + rows.
expect(html).toContain('data-slot="documents-date-group"');
expect(html).toContain('data-slot="documents-row"');
// Titles surface (title preferred over filename).
expect(html).toContain("Cardiology report");
expect(html).toContain("Blood panel");
// Kind + status badges from the EN bundle.
expect(html).toContain("Lab result");
expect(html).toContain("Stored");
// pendingCount badge.
expect(html).toContain("2 to review");
// nextCursor → load-more button.
expect(html).toContain("Load more");
});
it("shows the empty state when there are no documents", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
);
});
expect(html).toContain("No documents yet.");
expect(html).not.toContain('data-slot="documents-row"');
});
});
function fact(id: string): ExtractedFactDto {
return {
id,
factType: "OBSERVATION",
status: "PENDING",
confidence: 0.9,
needsReview: false,
data: {
label: "LDL Cholesterol",
code: null,
codeSystem: null,
value: 95,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-20",
},
provenance: { sourceText: "LDL 95 mg/dL", page: 0, confidence: 0.9 },
committedRecordId: null,
committedRecordType: null,
};
}
describe("<DocumentDetail>", () => {
function detail(over: Partial<InboundDocumentDetailDto> = {}) {
return {
...doc("d1", { title: "Cardiology report", status: "EXTRACTED" }),
facts: [fact("f1")],
...over,
} as InboundDocumentDetailDto;
}
it("renders the original link, edit + extract controls, and the review facts", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
},
);
expect(html).toContain('data-slot="documents-detail"');
// Original view link points at the raw-bytes route.
expect(html).toContain("/api/documents/inbound/d1/original");
expect(html).toContain('data-slot="documents-edit-trigger"');
// Extract button is present (status is not CONFIRMED).
expect(html).toContain('data-slot="documents-extract"');
expect(html).toContain("Extract facts");
// The staged fact's transcription summary renders for review.
expect(html).toContain("LDL Cholesterol");
expect(html).toContain("Save approved facts");
});
it("hides the extract button once the document is confirmed", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ status: "CONFIRMED", facts: [] }),
);
},
);
expect(html).not.toContain('data-slot="documents-extract"');
});
});
describe("<DocumentMetaEditForm>", () => {
it("renders the metadata fields seeded from the document", () => {
const html = render(
<DocumentMetaEditForm
doc={doc("d1", { title: "Old title", documentDate: "2026-06-20" })}
onSaved={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain('data-slot="documents-edit-form"');
expect(html).toContain('value="Old title"');
expect(html).toContain('value="2026-06-20"');
expect(html).toContain("Save");
});
});
describe("<ProviderUnsupportedNote>", () => {
it("renders the calm inline note, not a hard error", () => {
const html = render(<ProviderUnsupportedNote />);
expect(html).toContain('data-slot="documents-provider-note"');
expect(html).toContain("Configure a document-scan provider");
});
});
@@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildDocumentListSearch,
documentDateKey,
groupDocumentsByDate,
isProviderUnsupportedError,
} from "../library-utils";
function doc(
id: string,
over: Partial<InboundDocumentDto> = {},
): InboundDocumentDto {
return {
id,
kind: "OTHER",
title: null,
filename: `${id}.pdf`,
mimeType: "application/pdf",
byteSize: 1000,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: null,
errorReason: null,
factCount: 0,
pendingCount: 0,
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T08:00:00.000Z",
...over,
};
}
describe("buildDocumentListSearch", () => {
it("emits the active filters, sort, order, and limit", () => {
const qs = buildDocumentListSearch(
{
q: "cardio",
kind: "LAB_RESULT",
from: "2026-01-01",
to: "2026-06-01",
sort: "title",
order: "asc",
},
null,
25,
);
const sp = new URLSearchParams(qs);
expect(sp.get("q")).toBe("cardio");
expect(sp.get("kind")).toBe("LAB_RESULT");
expect(sp.get("from")).toBe("2026-01-01");
expect(sp.get("to")).toBe("2026-06-01");
expect(sp.get("sort")).toBe("title");
expect(sp.get("order")).toBe("asc");
expect(sp.get("limit")).toBe("25");
expect(sp.has("cursor")).toBe(false);
});
it("omits empty filters and appends the keyset cursor when present", () => {
const qs = buildDocumentListSearch(
{ sort: "documentDate", order: "desc" },
"cursor-123",
);
const sp = new URLSearchParams(qs);
expect(sp.has("q")).toBe(false);
expect(sp.has("kind")).toBe(false);
expect(sp.get("sort")).toBe("documentDate");
expect(sp.get("order")).toBe("desc");
expect(sp.get("cursor")).toBe("cursor-123");
});
});
describe("documentDateKey", () => {
it("prefers the user filing date", () => {
expect(documentDateKey(doc("a", { documentDate: "2026-03-15" }))).toBe(
"2026-03-15",
);
});
it("falls back to the upload day from createdAt", () => {
expect(
documentDateKey(doc("a", { createdAt: "2026-06-20T08:00:00.000Z" })),
).toBe("2026-06-20");
});
});
describe("groupDocumentsByDate", () => {
it("buckets consecutive same-date documents, preserving server order", () => {
const groups = groupDocumentsByDate([
doc("a", { documentDate: "2026-06-20" }),
doc("b", { documentDate: "2026-06-20" }),
doc("c", { documentDate: "2026-06-19" }),
]);
expect(groups.map((g) => g.key)).toEqual(["2026-06-20", "2026-06-19"]);
expect(groups[0].documents.map((d) => d.id)).toEqual(["a", "b"]);
expect(groups[1].documents.map((d) => d.id)).toEqual(["c"]);
});
it("returns an empty array for no documents", () => {
expect(groupDocumentsByDate([])).toEqual([]);
});
});
describe("isProviderUnsupportedError", () => {
it("matches the extract 422 provider-unsupported signal", () => {
const err = new ApiError("no provider", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
expect(isProviderUnsupportedError(err)).toBe(true);
});
it("rejects other API errors and non-errors", () => {
expect(
isProviderUnsupportedError(
new ApiError("rate", 429, {
errorCode: "documents.inbound.rateLimited",
}),
),
).toBe(false);
expect(isProviderUnsupportedError(new Error("boom"))).toBe(false);
expect(isProviderUnsupportedError(null)).toBe(false);
});
});
@@ -1,22 +1,55 @@
"use client";
/**
* v1.25.0 (W-DOCS-IN) — inbound clinical documents review-then-confirm UI.
* v1.25 — Documents library.
*
* Upload a doctor report / discharge letter, then review the STRUCTURED FACTS
* the OCR/vision provider transcribed before any of them reaches the
* structured stores. The screen reproduces what the document stated — it never
* interprets. A low-confidence fact fails closed: it cannot be approved until
* the user edits it.
* A self-hoster files any clinical document (doctor report, discharge letter,
* lab result, imaging, prescription, …). Upload is STORE-ONLY and always works,
* even with no AI provider configured — a file is filed with optional title /
* category / filing date. The library groups documents by date, with search, a
* category filter, sort, and keyset pagination.
*
* Each document opens a detail panel: view / download the original, edit its
* metadata, optionally run AI extraction (the explicit, separate enhancement),
* then review-then-confirm the STRUCTURED FACTS the provider transcribed before
* any reaches the structured stores. The screen reproduces what the document
* stated — it never interprets. A low-confidence fact fails closed: it cannot
* be approved until the user edits it. Absent a provider, extraction surfaces a
* calm inline note (configure one) — never a hard error; the stored document is
* untouched.
*/
import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { ExternalLink, FileScan, Loader2, Upload } from "lucide-react";
import {
ArrowDownAZ,
ArrowUpAZ,
ExternalLink,
FileScan,
Info,
Loader2,
Pencil,
Search,
Sparkles,
Upload,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
@@ -25,9 +58,12 @@ import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentListParams } from "@/lib/query-keys/documents";
import {
DOCUMENT_LIST_SORTS,
INBOUND_DOCUMENT_KINDS,
type ConditionFactData,
type DocumentListSort,
type ExtractedFactDto,
type InboundDocumentDetailDto,
type InboundDocumentDto,
@@ -35,28 +71,76 @@ import {
type MedicationStatementFactData,
type ObservationFactData,
} from "@/lib/validations/inbound-documents";
import {
buildDocumentListSearch,
formatDateGroupLabel,
groupDocumentsByDate,
isProviderUnsupportedError,
} from "./library-utils";
type ListResponse = { documents: InboundDocumentDto[] };
type ListResponse = {
documents: InboundDocumentDto[];
nextCursor: string | null;
};
type Decision = "approve" | "reject";
export function InboundDocumentsView() {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const queryClient = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const [kind, setKind] = useState<InboundDocumentKindValue>("DOCTOR_REPORT");
// Upload form metadata (filed with the document immediately).
const [uploadTitle, setUploadTitle] = useState("");
const [uploadKind, setUploadKind] =
useState<InboundDocumentKindValue>("DOCTOR_REPORT");
const [uploadDate, setUploadDate] = useState("");
// Library toolbar state.
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
const [kindFilter, setKindFilter] = useState<"" | InboundDocumentKindValue>(
"",
);
const [sort, setSort] = useState<DocumentListSort>("documentDate");
const [order, setOrder] = useState<"asc" | "desc">("desc");
const [selectedId, setSelectedId] = useState<string | null>(null);
const list = useQuery({
queryKey: queryKeys.inboundDocuments(),
queryFn: () =>
apiFetch<ListResponse>("/api/documents/inbound", { method: "GET" }),
// Debounce the search box so a query fires per pause, not per keystroke.
useEffect(() => {
const handle = setTimeout(() => setDebouncedQ(q), 300);
return () => clearTimeout(handle);
}, [q]);
const filters: DocumentListParams = {
q: debouncedQ.trim() || undefined,
kind: kindFilter || undefined,
sort,
order,
};
const list = useInfiniteQuery({
queryKey: queryKeys.inboundDocumentList(filters),
queryFn: ({ pageParam }) =>
apiFetch<ListResponse>(
`/api/documents/inbound?${buildDocumentListSearch(filters, pageParam)}`,
{ method: "GET" },
),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const documents = list.data?.pages.flatMap((p) => p.documents) ?? [];
const groups = groupDocumentsByDate(documents);
const isFiltered = Boolean(filters.q || filters.kind);
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
fd.append("file", file);
fd.append("kind", kind);
fd.append("kind", uploadKind);
if (uploadTitle.trim()) fd.append("title", uploadTitle.trim());
if (uploadDate) fd.append("documentDate", uploadDate);
return apiFetch<InboundDocumentDto>("/api/documents/inbound", {
method: "POST",
body: fd,
@@ -67,6 +151,8 @@ export function InboundDocumentsView() {
toast.success(t("documents.toast.uploaded"));
void invalidateKeys(queryClient, [queryKeys.documents()]);
setSelectedId(doc.id);
setUploadTitle("");
setUploadDate("");
if (fileRef.current) fileRef.current.value = "";
},
onError: () => toast.error(t("documents.toast.uploadFailed")),
@@ -84,112 +170,287 @@ export function InboundDocumentsView() {
</div>
</header>
{/* Upload */}
{/* Upload — store-only, always available. */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("documents.upload.label")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-col gap-1">
<Label htmlFor="doc-kind">{t("documents.upload.kindLabel")}</Label>
<NativeSelect
id="doc-kind"
value={kind}
onChange={(e) =>
setKind(e.target.value as InboundDocumentKindValue)
}
>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<Input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="max-w-xs"
aria-label={t("documents.upload.fileLabel")}
disabled={upload.isPending}
/>
<Button
onClick={() => {
const file = fileRef.current?.files?.[0];
if (file) upload.mutate(file);
}}
disabled={upload.isPending}
>
{upload.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="doc-title">
{t("documents.upload.titleLabel")}
</Label>
<Input
id="doc-title"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder={t("documents.upload.titlePlaceholder")}
disabled={upload.isPending}
/>
) : (
<Upload className="h-4 w-4" aria-hidden />
)}
{upload.isPending
? t("documents.upload.uploading")
: t("documents.upload.button")}
</Button>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-kind">
{t("documents.upload.kindLabel")}
</Label>
<NativeSelect
id="doc-kind"
value={uploadKind}
onChange={(e) =>
setUploadKind(e.target.value as InboundDocumentKindValue)
}
disabled={upload.isPending}
>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-date">
{t("documents.upload.dateLabel")}
</Label>
<Input
id="doc-date"
type="date"
value={uploadDate}
onChange={(e) => setUploadDate(e.target.value)}
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}
/>
</div>
</div>
<div>
<Button
onClick={() => {
const file = fileRef.current?.files?.[0];
if (file) upload.mutate(file);
}}
disabled={upload.isPending}
>
{upload.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Upload className="h-4 w-4" aria-hidden />
)}
{upload.isPending
? t("documents.upload.uploading")
: t("documents.upload.button")}
</Button>
</div>
</CardContent>
</Card>
{/* Document list */}
<section className="flex flex-col gap-2">
{/* Toolbar — search / category filter / sort. */}
<div
className="flex flex-col gap-2 sm:flex-row sm:items-end"
data-slot="documents-toolbar"
>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="doc-search">
{t("documents.toolbar.searchLabel")}
</Label>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2"
aria-hidden
/>
<Input
id="doc-search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("documents.toolbar.searchPlaceholder")}
className="pl-8"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-kind-filter">
{t("documents.toolbar.kindFilterLabel")}
</Label>
<NativeSelect
id="doc-kind-filter"
value={kindFilter}
onChange={(e) =>
setKindFilter(e.target.value as "" | InboundDocumentKindValue)
}
>
<option value="">{t("documents.toolbar.allKinds")}</option>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-sort">{t("documents.toolbar.sortLabel")}</Label>
<div className="flex items-center gap-1">
<NativeSelect
id="doc-sort"
value={sort}
onChange={(e) => setSort(e.target.value as DocumentListSort)}
>
{DOCUMENT_LIST_SORTS.map((s) => (
<option key={s} value={s}>
{t(`documents.toolbar.sort.${s}`)}
</option>
))}
</NativeSelect>
<Button
variant="outline"
size="icon"
aria-label={t(
order === "asc"
? "documents.toolbar.orderAsc"
: "documents.toolbar.orderDesc",
)}
onClick={() => setOrder((o) => (o === "asc" ? "desc" : "asc"))}
>
{order === "asc" ? (
<ArrowUpAZ className="h-4 w-4" aria-hidden />
) : (
<ArrowDownAZ className="h-4 w-4" aria-hidden />
)}
</Button>
</div>
</div>
</div>
{/* Library list — grouped by date, keyset-paginated. */}
<section className="flex flex-col gap-4" data-slot="documents-library">
{list.isLoading ? (
// Skeleton rows (not a centered spinner) so the resolved list lands
// in place without a layout jump — the app's loading convention.
<>
<div className="flex flex-col gap-2" data-slot="documents-loading">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
<Skeleton key={i} className="h-12 w-full rounded-md" />
))}
</>
) : !list.data || list.data.documents.length === 0 ? (
</div>
) : documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.list.empty")}
{isFiltered
? t("documents.list.noResults")
: t("documents.list.empty")}
</p>
) : (
list.data.documents.map((doc) => (
<button
key={doc.id}
type="button"
aria-expanded={selectedId === doc.id}
onClick={() =>
setSelectedId(selectedId === doc.id ? null : doc.id)
}
className="hover:bg-accent flex min-h-11 items-center justify-between rounded-md border px-3 py-2 text-left text-sm"
>
<span className="truncate">
{doc.filename ?? t(`documents.kind.${doc.kind}`)}
</span>
<span className="flex items-center gap-2">
<Badge variant="secondary">
{t(`documents.status.${doc.status}`)}
</Badge>
{doc.pendingCount > 0 ? (
<Badge variant="outline">{doc.pendingCount}</Badge>
<>
{groups.map((group) => (
<div key={group.key} className="flex flex-col gap-2">
<h2
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
data-slot="documents-date-group"
>
{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}
</span>
</button>
))
</div>
))}
{list.hasNextPage ? (
<Button
variant="outline"
onClick={() => void list.fetchNextPage()}
disabled={list.isFetchingNextPage}
>
{list.isFetchingNextPage ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{list.isFetchingNextPage
? t("documents.list.loadingMore")
: t("documents.list.loadMore")}
</Button>
) : null}
</>
)}
</section>
{selectedId ? (
<DocumentReview
documentId={selectedId}
onClosed={() => setSelectedId(null)}
/>
) : null}
</div>
);
}
function DocumentReview({
/** A single library row — title (or filename), kind + status badges, counts. */
function DocumentRow({
doc,
expanded,
onToggle,
}: {
doc: InboundDocumentDto;
expanded: boolean;
onToggle: () => void;
}) {
const { t } = useTranslations();
const display = doc.title ?? doc.filename ?? t(`documents.kind.${doc.kind}`);
return (
<button
type="button"
aria-expanded={expanded}
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"
>
<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]">
{t(`documents.kind.${doc.kind}`)}
</Badge>
<Badge variant="secondary" className="text-[10px]">
{t(`documents.status.${doc.status}`)}
</Badge>
</span>
</span>
{doc.pendingCount > 0 ? (
<Badge variant="outline" className="shrink-0">
{t("documents.list.pending", { count: doc.pendingCount })}
</Badge>
) : doc.factCount > 0 ? (
<Badge variant="secondary" className="shrink-0">
{t("documents.list.facts", { count: doc.factCount })}
</Badge>
) : null}
</button>
);
}
export function DocumentDetail({
documentId,
onClosed,
}: {
@@ -199,6 +460,7 @@ function DocumentReview({
const { t } = useTranslations();
const queryClient = useQueryClient();
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const [editOpen, setEditOpen] = useState(false);
const detail = useQuery({
queryKey: queryKeys.inboundDocument(documentId),
@@ -209,6 +471,23 @@ function DocumentReview({
),
});
const extract = useMutation({
// Empty body → vision mode over the stored original.
mutationFn: () =>
apiPost(`/api/documents/inbound/${documentId}/extract`, undefined),
onSuccess: () => {
toast.success(t("documents.toast.extracted"));
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)) {
toast.error(t("documents.toast.extractFailed"));
}
},
});
const confirm = useMutation({
mutationFn: () =>
apiPost(`/api/documents/inbound/${documentId}/confirm`, {
@@ -250,65 +529,93 @@ function DocumentReview({
}
if (!detail.data) return null;
const pending = detail.data.facts.filter((f) => f.status === "PENDING");
const doc = detail.data;
const pending = doc.facts.filter((f) => f.status === "PENDING");
const decidedCount = Object.keys(decisions).length;
const canExtract = doc.status !== "CONFIRMED";
const showProviderNote = isProviderUnsupportedError(extract.error);
return (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">
{t("documents.review.title")}
</CardTitle>
<div className="flex items-center gap-1">
{/* View the original uploaded document. The route is same-origin and
cookie-authenticated, so a new tab carries the session; a PDF /
image renders inline, other types download. */}
<Card data-slot="documents-detail">
<CardHeader className="flex-col items-start gap-2 space-y-0">
<CardTitle className="text-base">{doc.title ?? doc.filename}</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
session; a PDF / image renders inline, other types download. */}
<a
href={`/api/documents/inbound/${documentId}/original`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" aria-hidden />
{t("documents.review.viewOriginal")}
{t("documents.detail.viewOriginal")}
</a>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setEditOpen(true)}
data-slot="documents-edit-trigger"
>
<Pencil className="h-4 w-4" aria-hidden />
{t("documents.detail.edit")}
</Button>
{canExtract ? (
<Button
variant="ghost"
size="sm"
onClick={() => extract.mutate()}
disabled={extract.isPending}
data-slot="documents-extract"
>
{extract.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Sparkles className="h-4 w-4" aria-hidden />
)}
{extract.isPending
? t("documents.detail.extracting")
: t("documents.detail.extract")}
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => discard.mutate()}
disabled={discard.isPending}
>
{t("documents.review.discard")}
{t("documents.detail.discard")}
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{pending.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.empty.noFacts")}
</p>
) : (
pending.map((fact) => (
<FactCard
key={fact.id}
documentId={documentId}
fact={fact}
decision={decisions[fact.id]}
onDecision={(d) =>
setDecisions((prev) => {
const next = { ...prev };
if (d === null) delete next[fact.id];
else next[fact.id] = d;
return next;
})
}
/>
))
)}
{showProviderNote ? <ProviderUnsupportedNote /> : null}
{pending.length > 0 ? (
<>
<p className="text-muted-foreground text-sm">
{t("documents.review.title")}
</p>
{pending.map((fact) => (
<FactCard
key={fact.id}
documentId={documentId}
fact={fact}
decision={decisions[fact.id]}
onDecision={(d) =>
setDecisions((prev) => {
const next = { ...prev };
if (d === null) delete next[fact.id];
else next[fact.id] = d;
return next;
})
}
/>
))}
<Separator />
<Button
onClick={() => confirm.mutate()}
@@ -319,12 +626,127 @@ function DocumentReview({
: t("documents.review.confirm")}
</Button>
</>
) : null}
) : (
<p className="text-muted-foreground text-sm">
{doc.factCount > 0
? t("documents.empty.noFacts")
: t("documents.detail.notExtracted")}
</p>
)}
</CardContent>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("documents.edit.title")}</DialogTitle>
<DialogDescription>
{t("documents.edit.subtitle")}
</DialogDescription>
</DialogHeader>
<DocumentMetaEditForm
doc={doc}
onSaved={() => setEditOpen(false)}
onCancel={() => setEditOpen(false)}
/>
</DialogContent>
</Dialog>
</Card>
);
}
/** 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>
);
}
/** Metadata edit (rename / recategorise / set the filing date). PATCHes. */
export function DocumentMetaEditForm({
doc,
onSaved,
onCancel,
}: {
doc: InboundDocumentDto;
onSaved: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [title, setTitle] = useState(doc.title ?? "");
const [kind, setKind] = useState<InboundDocumentKindValue>(doc.kind);
const [date, setDate] = useState(doc.documentDate ?? "");
const save = useMutation({
mutationFn: () =>
apiPatch(`/api/documents/inbound/${doc.id}`, {
title: title.trim() === "" ? null : title.trim(),
kind,
documentDate: date === "" ? null : date,
}),
onSuccess: () => {
toast.success(t("documents.toast.updated"));
void invalidateKeys(queryClient, [queryKeys.documents()]);
onSaved();
},
onError: () => toast.error(t("documents.toast.updateFailed")),
});
return (
<div className="flex flex-col gap-3" data-slot="documents-edit-form">
<div className="flex flex-col gap-1">
<Label htmlFor="edit-doc-title">{t("documents.edit.titleLabel")}</Label>
<Input
id="edit-doc-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("documents.upload.titlePlaceholder")}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="edit-doc-kind">{t("documents.edit.kindLabel")}</Label>
<NativeSelect
id="edit-doc-kind"
value={kind}
onChange={(e) => setKind(e.target.value as InboundDocumentKindValue)}
>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="edit-doc-date">{t("documents.edit.dateLabel")}</Label>
<Input
id="edit-doc-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={save.isPending}>
{t("documents.edit.cancel")}
</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending
? t("documents.edit.saving")
: t("documents.edit.save")}
</Button>
</DialogFooter>
</div>
);
}
function FactCard({
documentId,
fact,
+96
View File
@@ -0,0 +1,96 @@
/**
* v1.25 — pure helpers for the documents library (search-param building, date
* grouping, provider-unsupported classification). Kept out of the client
* component so they stay trivially unit-testable without a render harness.
*/
import { ApiError } from "@/lib/api/api-fetch";
import type { DocumentListParams } from "@/lib/query-keys/documents";
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
/**
* Build the `/api/documents/inbound` query string from the active filters and
* a keyset cursor. Empty / undefined fields are omitted so the URL stays clean
* and the server applies its own defaults. The cursor is the `nextCursor` the
* previous page returned (null on the first page).
*/
export function buildDocumentListSearch(
params: DocumentListParams,
cursor: string | null,
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
): string {
const sp = new URLSearchParams();
if (params.q && params.q.trim() !== "") sp.set("q", params.q.trim());
if (params.kind) sp.set("kind", params.kind);
if (params.from) sp.set("from", params.from);
if (params.to) sp.set("to", params.to);
sp.set("sort", params.sort);
sp.set("order", params.order);
sp.set("limit", String(limit));
if (cursor) sp.set("cursor", cursor);
return sp.toString();
}
/** The date a document files under — its user filing date, else its upload day. */
export function documentDateKey(doc: InboundDocumentDto): string {
if (doc.documentDate) return doc.documentDate;
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
return doc.createdAt.slice(0, 10);
}
export interface DocumentDateGroup {
/** The YYYY-MM-DD key the group files under. */
key: string;
documents: InboundDocumentDto[];
}
/**
* Group an already-sorted document list into consecutive date buckets,
* preserving the server's sort order. A document's bucket is its `documentDate`
* (fallback: its upload day). The first time a date appears it opens a new
* group; later documents on the same date join it. Order is preserved exactly
* as received, so the server stays the single source of truth for sorting.
*/
export function groupDocumentsByDate(
documents: InboundDocumentDto[],
): DocumentDateGroup[] {
const groups: DocumentDateGroup[] = [];
let current: DocumentDateGroup | null = null;
for (const doc of documents) {
const key = documentDateKey(doc);
if (!current || current.key !== key) {
current = { key, documents: [doc] };
groups.push(current);
} else {
current.documents.push(doc);
}
}
return groups;
}
/**
* True when an error is the extract route's 422 "no document-scan provider
* configured" signal. The UI surfaces this as a calm inline note (configure a
* provider) rather than a hard error toast — the stored document is untouched.
*/
export function isProviderUnsupportedError(error: unknown): boolean {
return (
error instanceof ApiError &&
error.meta?.errorCode === "documents.inbound.providerUnsupported"
);
}
/** 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;
}
}
+35 -5
View File
@@ -1,12 +1,42 @@
/**
* Query keys — inbound clinical documents (v1.25, W-DOCS-IN): the document
* list and a single document's review detail (document + staged facts).
* Part of the centralized factory; aggregated in `./index.ts`. Any inbound
* write (upload / edit / confirm / discard) invalidates the `["documents"]`
* prefix so the list and the open review repaint in lockstep.
* Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable
* document list (search / category filter / sort, keyset-paginated) and a
* single document's detail (document + staged facts). Part of the centralized
* factory; aggregated in `./index.ts`. Any document write (upload / edit /
* extract / confirm / discard) invalidates the `["documents"]` prefix so the
* list and the open detail repaint in lockstep.
*/
import type {
DocumentListSort,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
/**
* The parameter set that distinguishes one library list view from another.
* Lives in the key so two different filter/sort combinations never share a
* cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a
* bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }`
* resolve to the same key.
*/
export interface DocumentListParams {
q?: string;
kind?: InboundDocumentKindValue;
from?: string;
to?: string;
sort: DocumentListSort;
order: "asc" | "desc";
}
export const documentKeys = {
documents: () => ["documents"] as const,
inboundDocuments: () => ["documents", "inbound"] as const,
/**
* The parameterised library list (keyset-paginated via `useInfiniteQuery`).
* The literal `"list"` segment keeps it distinct from a single-document key
* (`inboundDocument(id)`); cuid ids never collide with it. Stays under the
* `["documents", "inbound"]` prefix so prefix invalidation still reaches it.
*/
inboundDocumentList: (params: DocumentListParams) =>
["documents", "inbound", "list", params] as const,
inboundDocument: (id: string) => ["documents", "inbound", id] as const,
};