mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge v1.25.1: documents library UI, metadata editing, optional extraction, rename to Documents
This commit is contained in:
commit
84543ad540
11 files changed
+1606
-284
No files matched your search
+93
-24
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user