diff --git a/messages/de.json b/messages/de.json index 0eb28d263..ccbf7c61c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 0abea11d0..deb367851 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index c2bf6db57..da3648da8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index a369f586f..9950fc359 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 42f1ee1c1..b614b73d9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 3ea0db04d..3a0864934 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/documents/__tests__/inbound-documents-view.test.tsx b/src/components/documents/__tests__/inbound-documents-view.test.tsx new file mode 100644 index 000000000..f414314c9 --- /dev/null +++ b/src/components/documents/__tests__/inbound-documents-view.test.tsx @@ -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( + "@/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 { + 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( + + {node} + , + ); +} + +describe(" library", () => { + it("renders the grouped list with titles, badges, and load-more", () => { + const html = render(, (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(, (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("", () => { + function detail(over: Partial = {}) { + 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( + {}} />, + (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( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ status: "CONFIRMED", facts: [] }), + ); + }, + ); + expect(html).not.toContain('data-slot="documents-extract"'); + }); +}); + +describe("", () => { + it("renders the metadata fields seeded from the document", () => { + const html = render( + {}} + 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("", () => { + it("renders the calm inline note, not a hard error", () => { + const html = render(); + expect(html).toContain('data-slot="documents-provider-note"'); + expect(html).toContain("Configure a document-scan provider"); + }); +}); diff --git a/src/components/documents/__tests__/library-utils.test.ts b/src/components/documents/__tests__/library-utils.test.ts new file mode 100644 index 000000000..249280a35 --- /dev/null +++ b/src/components/documents/__tests__/library-utils.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx index d27472135..e67388ef7 100644 --- a/src/components/documents/inbound-documents-view.tsx +++ b/src/components/documents/inbound-documents-view.tsx @@ -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(null); - const [kind, setKind] = useState("DOCTOR_REPORT"); + + // Upload form metadata (filed with the document immediately). + const [uploadTitle, setUploadTitle] = useState(""); + const [uploadKind, setUploadKind] = + useState("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("documentDate"); + const [order, setOrder] = useState<"asc" | "desc">("desc"); + const [selectedId, setSelectedId] = useState(null); - const list = useQuery({ - queryKey: queryKeys.inboundDocuments(), - queryFn: () => - apiFetch("/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( + `/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("/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() { - {/* Upload */} + {/* Upload — store-only, always available. */} {t("documents.upload.label")} - -
- - - setKind(e.target.value as InboundDocumentKindValue) - } - > - {INBOUND_DOCUMENT_KINDS.map((k) => ( - - ))} - -
- - + +
+ + + setUploadKind(e.target.value as InboundDocumentKindValue) + } + disabled={upload.isPending} + > + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ + setUploadDate(e.target.value)} + disabled={upload.isPending} + /> +
+
+ + +
+ +
+ +
- {/* Document list */} -
+ {/* Toolbar — search / category filter / sort. */} +
+
+ +
+ + setQ(e.target.value)} + placeholder={t("documents.toolbar.searchPlaceholder")} + className="pl-8" + /> +
+
+
+ + + setKindFilter(e.target.value as "" | InboundDocumentKindValue) + } + > + + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ +
+ setSort(e.target.value as DocumentListSort)} + > + {DOCUMENT_LIST_SORTS.map((s) => ( + + ))} + + +
+
+
+ + {/* Library list — grouped by date, keyset-paginated. */} +
{list.isLoading ? ( // Skeleton rows (not a centered spinner) so the resolved list lands // in place without a layout jump — the app's loading convention. - <> +
{[0, 1, 2].map((i) => ( - + ))} - - ) : !list.data || list.data.documents.length === 0 ? ( +
+ ) : documents.length === 0 ? (

- {t("documents.list.empty")} + {isFiltered + ? t("documents.list.noResults") + : t("documents.list.empty")}

) : ( - list.data.documents.map((doc) => ( - - )) + + ))} + + {list.hasNextPage ? ( + + ) : null} + )}
- - {selectedId ? ( - setSelectedId(null)} - /> - ) : null} ); } -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 ( + + ); +} + +export function DocumentDetail({ documentId, onClosed, }: { @@ -199,6 +460,7 @@ function DocumentReview({ const { t } = useTranslations(); const queryClient = useQueryClient(); const [decisions, setDecisions] = useState>({}); + 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 ( - - - - {t("documents.review.title")} - -
- {/* 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. */} + + + {doc.title ?? doc.filename} +
+ + {canExtract ? ( + + ) : null}
- {pending.length === 0 ? ( -

- {t("documents.empty.noFacts")} -

- ) : ( - pending.map((fact) => ( - - setDecisions((prev) => { - const next = { ...prev }; - if (d === null) delete next[fact.id]; - else next[fact.id] = d; - return next; - }) - } - /> - )) - )} + {showProviderNote ? : null} {pending.length > 0 ? ( <> +

+ {t("documents.review.title")} +

+ {pending.map((fact) => ( + + setDecisions((prev) => { + const next = { ...prev }; + if (d === null) delete next[fact.id]; + else next[fact.id] = d; + return next; + }) + } + /> + ))} - ) : null} + ) : ( +

+ {doc.factCount > 0 + ? t("documents.empty.noFacts") + : t("documents.detail.notExtracted")} +

+ )}
+ + + + + {t("documents.edit.title")} + + {t("documents.edit.subtitle")} + + + setEditOpen(false)} + onCancel={() => setEditOpen(false)} + /> + +
); } +/** The calm inline note shown when extraction needs a provider that's absent. */ +export function ProviderUnsupportedNote() { + const { t } = useTranslations(); + return ( +
+ +

{t("documents.note.providerUnsupported")}

+
+ ); +} + +/** 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(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 ( +
+
+ + setTitle(e.target.value)} + placeholder={t("documents.upload.titlePlaceholder")} + /> +
+
+ + setKind(e.target.value as InboundDocumentKindValue)} + > + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ + setDate(e.target.value)} + /> +
+ + + + +
+ ); +} + function FactCard({ documentId, fact, diff --git a/src/components/documents/library-utils.ts b/src/components/documents/library-utils.ts new file mode 100644 index 000000000..6400c735d --- /dev/null +++ b/src/components/documents/library-utils.ts @@ -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; + } +} diff --git a/src/lib/query-keys/documents.ts b/src/lib/query-keys/documents.ts index 46289f42e..73774354f 100644 --- a/src/lib/query-keys/documents.ts +++ b/src/lib/query-keys/documents.ts @@ -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, };