mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(documents): pick documents from Paperless-ngx or Papra in the vault and wherever documents are linked
A settings card under Integrations connects either system and tests it before saving. An import button beside Upload in Documents, on a condition's documents card, and beside Add in the visit and vaccination document pickers opens a sheet that searches by name with tag and date filters, shows what HealthLog already holds, and imports the ticked documents one at a time with a result per row. The buttons appear only for a person in their own record with a connection; strings in all seven languages.
This commit is contained in:
1 parent
92f2cc729f
commit
d109d67889
21 files changed
+2200
-20
No files matched your search
@@ -6698,6 +6698,32 @@
|
||||
"showChecklist": "Checkliste wieder anzeigen",
|
||||
"checklistShown": "Die Checkliste ist wieder auf dem Dashboard.",
|
||||
"checklistNothingHidden": "Die Checkliste war nicht ausgeblendet; sie öffnet sich auf dem Dashboard."
|
||||
},
|
||||
"documentSources": {
|
||||
"title": "Dokumentarchive",
|
||||
"description": "Paperless-ngx oder Papra aus HealthLog durchsuchen und ausgewählte Dokumente importieren.",
|
||||
"explainer": "Ein Import-Knopf erscheint überall, wo du Dokumente verknüpfst: bei Arztbesuchen, Impfungen und Erkrankungen sowie unter Dokumente. HealthLog speichert von jedem importierten Dokument eine eigene verschlüsselte Kopie und fragt dein Archiv nur, wenn du suchst oder importierst.",
|
||||
"systemLabel": "Archiv",
|
||||
"statusConnected": "Verbunden",
|
||||
"statusNotConnected": "Nicht verbunden",
|
||||
"connectedSince": "Verbunden, zuletzt geprüft am {date}",
|
||||
"originRemoved": "Diese Adresse steht nicht mehr auf der Liste der erlaubten Archive dieses Servers. Frag die Administration oder speichere eine andere Adresse.",
|
||||
"baseUrl": "Adresse",
|
||||
"allowedOrigins": "Auf diesem Server erlaubt: {origins}",
|
||||
"organizationId": "Organisations-ID",
|
||||
"organizationHelp": "Der Teil nach /organizations/ in der Adresszeile von Papra.",
|
||||
"token": "API-Token",
|
||||
"tokenSaved": "Gespeichert. Leer lassen, um es zu behalten.",
|
||||
"tokenHelpPaperless": "In Paperless-ngx: Mein Profil, API-Token.",
|
||||
"tokenHelpPapra": "In Papra: ein API-Schlüssel mit documents:read und tags:read.",
|
||||
"save": "Speichern und testen",
|
||||
"saving": "Wird getestet …",
|
||||
"saved": "Verbunden. Das Archiv hat geantwortet.",
|
||||
"test": "Testen",
|
||||
"testOk": "Das Archiv hat in {latency} ms geantwortet.",
|
||||
"disconnect": "Trennen",
|
||||
"disconnectTitle": "{name} trennen?",
|
||||
"disconnectDescription": "HealthLog vergisst Adresse und Token. Bereits importierte Dokumente bleiben in HealthLog."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -10147,6 +10173,65 @@
|
||||
"body": "Beim Lesen mit KI geht dies an deinen KI-Dienst außerhalb dieses Servers. Darf HealthLog Dokumente, Laborbefunde und Medikamentenbeschreibungen zum Lesen senden?",
|
||||
"grant": "Lesen erlauben",
|
||||
"error": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuch es erneut."
|
||||
},
|
||||
"sourcePicker": {
|
||||
"open": "Aus Archiv importieren",
|
||||
"openNamed": "Aus {name} importieren",
|
||||
"title": "Aus {name} importieren",
|
||||
"description": "Nach Namen suchen, dann die Dokumente anhaken, die HealthLog übernehmen soll.",
|
||||
"sourceLabel": "Archiv",
|
||||
"searchLabel": "Dokumentname",
|
||||
"searchPlaceholder": "Nach Namen suchen",
|
||||
"tagLabel": "Tag",
|
||||
"tagAny": "Alle Tags",
|
||||
"fromLabel": "Von",
|
||||
"toLabel": "Bis",
|
||||
"clearFilters": "Tag und Datum zurücksetzen",
|
||||
"rangeInvalid": "Das Enddatum liegt vor dem Startdatum.",
|
||||
"empty": "{name} hat keine Dokumente zum Anzeigen.",
|
||||
"noMatch": "Keine passenden Dokumente.",
|
||||
"loadMore": "Mehr laden",
|
||||
"stateImported": "In HealthLog",
|
||||
"stateDeleted": "In HealthLog gelöscht",
|
||||
"selectedCount": "{count} von höchstens {max} ausgewählt",
|
||||
"importAction": "{count} importieren",
|
||||
"importing": "Import läuft, ein Dokument nach dem anderen …",
|
||||
"done": "Fertig",
|
||||
"summary": "Importiert: {imported}. Schon vorhanden: {present}. Nicht importiert: {failed}.",
|
||||
"stopped": "Abgebrochen: {reason}",
|
||||
"result": {
|
||||
"waiting": "Wartet",
|
||||
"working": "Wird importiert …",
|
||||
"imported": "Importiert",
|
||||
"importedLinked": "Importiert und verknüpft",
|
||||
"duplicate": "Schon in HealthLog",
|
||||
"duplicateLinked": "Schon in HealthLog, jetzt verknüpft",
|
||||
"deleted": "In HealthLog gelöscht, bleibt gelöscht"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Dokumentarchive sind auf diesem Server nicht freigeschaltet.",
|
||||
"notConnected": "Dieses Archiv ist nicht verbunden. Verbinde es unter Einstellungen, Integrationen.",
|
||||
"originNotAllowed": "Diese Adresse steht nicht auf der Liste der erlaubten Archive dieses Servers. Bitte die Administration, sie aufzunehmen.",
|
||||
"unreachable": "Das Archiv war nicht erreichbar. Prüfe, ob es läuft und die Adresse stimmt.",
|
||||
"redirected": "Das Archiv hat die Anfrage umgeleitet. Trage die Adresse ein, auf die es umleitet.",
|
||||
"authRefused": "Das Archiv hat das Token abgelehnt. Prüfe das Token und seine Berechtigungen.",
|
||||
"versionTooOld": "Das Archiv ist zu alt. Nötig ist Paperless-ngx 2.16 oder neuer.",
|
||||
"badResponse": "Die Antwort des Archivs ließ sich nicht lesen. Prüfe, ob die Adresse auf das Archiv selbst zeigt.",
|
||||
"notFound": "Das Archiv kennt dieses Dokument oder diese Organisation nicht.",
|
||||
"linkTargetNotFound": "Der Eintrag, mit dem verknüpft werden sollte, existiert nicht mehr.",
|
||||
"rateLimited": "Zu viele Anfragen. Warte eine Minute und versuche es erneut.",
|
||||
"browserOnly": "Dokumentarchive lassen sich nur im Browser verwenden.",
|
||||
"invalidAddress": "Gib die vollständige Adresse ein, beginnend mit https:// oder http://.",
|
||||
"organizationRequired": "Gib die Organisations-ID aus Papra ein.",
|
||||
"tokenRequired": "Gib das API-Token ein.",
|
||||
"fileTooLarge": "Die Datei ist größer, als HealthLog annimmt.",
|
||||
"quotaExceeded": "Dein Dokumentenspeicher ist voll.",
|
||||
"fileType": "HealthLog nimmt diesen Dateityp nicht an.",
|
||||
"uploadLimit": "Das stündliche Upload-Limit ist erreicht. Versuche es später erneut.",
|
||||
"uploadBusy": "Andere Uploads laufen noch. Versuche es gleich noch einmal.",
|
||||
"moduleDisabled": "Dokumente sind in den Einstellungen ausgeschaltet.",
|
||||
"generic": "Etwas ist schiefgelaufen. Versuche es erneut."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -6698,6 +6698,32 @@
|
||||
"showChecklist": "Show the checklist again",
|
||||
"checklistShown": "The checklist is back on the dashboard.",
|
||||
"checklistNothingHidden": "The checklist was not hidden; it opens on the dashboard."
|
||||
},
|
||||
"documentSources": {
|
||||
"title": "Document archives",
|
||||
"description": "Search Paperless-ngx or Papra from HealthLog and import the documents you pick.",
|
||||
"explainer": "An import button appears wherever you link documents: on a visit, a vaccination and a condition, and in Documents. HealthLog keeps its own encrypted copy of each document you import, and asks your archive only when you search or import.",
|
||||
"systemLabel": "Archive",
|
||||
"statusConnected": "Connected",
|
||||
"statusNotConnected": "Not connected",
|
||||
"connectedSince": "Connected, last checked {date}",
|
||||
"originRemoved": "This address is no longer on the server's list of allowed archives. Ask the administrator, or save another address.",
|
||||
"baseUrl": "Address",
|
||||
"allowedOrigins": "Allowed on this server: {origins}",
|
||||
"organizationId": "Organization id",
|
||||
"organizationHelp": "The part after /organizations/ in the Papra address bar.",
|
||||
"token": "API token",
|
||||
"tokenSaved": "Saved. Leave empty to keep it.",
|
||||
"tokenHelpPaperless": "In Paperless-ngx: My Profile, API token.",
|
||||
"tokenHelpPapra": "In Papra: an API key with documents:read and tags:read.",
|
||||
"save": "Save and test",
|
||||
"saving": "Testing…",
|
||||
"saved": "Connected. The archive answered.",
|
||||
"test": "Test",
|
||||
"testOk": "The archive answered in {latency} ms.",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnectTitle": "Disconnect {name}?",
|
||||
"disconnectDescription": "HealthLog forgets the address and the token. Documents you already imported stay in HealthLog."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -10147,6 +10173,65 @@
|
||||
"body": "Reading with AI sends this to your AI service outside this server. Allow HealthLog to send documents, lab reports and medication descriptions for reading?",
|
||||
"grant": "Allow reading",
|
||||
"error": "The consent could not be saved. Please try again."
|
||||
},
|
||||
"sourcePicker": {
|
||||
"open": "Import from archive",
|
||||
"openNamed": "Import from {name}",
|
||||
"title": "Import from {name}",
|
||||
"description": "Search by name, then tick the documents HealthLog should copy.",
|
||||
"sourceLabel": "Archive",
|
||||
"searchLabel": "Document name",
|
||||
"searchPlaceholder": "Search by name",
|
||||
"tagLabel": "Tag",
|
||||
"tagAny": "Any tag",
|
||||
"fromLabel": "From",
|
||||
"toLabel": "To",
|
||||
"clearFilters": "Clear tag and dates",
|
||||
"rangeInvalid": "The end date is before the start date.",
|
||||
"empty": "{name} has no documents to show.",
|
||||
"noMatch": "No documents match.",
|
||||
"loadMore": "Load more",
|
||||
"stateImported": "In HealthLog",
|
||||
"stateDeleted": "Deleted in HealthLog",
|
||||
"selectedCount": "{count} of up to {max} selected",
|
||||
"importAction": "Import {count}",
|
||||
"importing": "Importing, one document at a time…",
|
||||
"done": "Done",
|
||||
"summary": "Imported: {imported}. Already there: {present}. Not imported: {failed}.",
|
||||
"stopped": "Stopped: {reason}",
|
||||
"result": {
|
||||
"waiting": "Waiting",
|
||||
"working": "Importing…",
|
||||
"imported": "Imported",
|
||||
"importedLinked": "Imported and linked",
|
||||
"duplicate": "Already in HealthLog",
|
||||
"duplicateLinked": "Already in HealthLog, now linked",
|
||||
"deleted": "Deleted in HealthLog, left alone"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Document archives are not enabled on this server.",
|
||||
"notConnected": "This archive is not connected. Connect it under Settings, Integrations.",
|
||||
"originNotAllowed": "This address is not on the server's list of allowed archives. Ask the administrator to add it.",
|
||||
"unreachable": "The archive could not be reached. Check that it is running and that the address is right.",
|
||||
"redirected": "The archive redirected the request. Enter the address it redirects to.",
|
||||
"authRefused": "The archive refused the token. Check the token and its permissions.",
|
||||
"versionTooOld": "The archive is too old. Paperless-ngx 2.16 or later is needed.",
|
||||
"badResponse": "The archive's answer could not be read. Check that the address points at the archive itself.",
|
||||
"notFound": "The archive has no such document, or no such organization.",
|
||||
"linkTargetNotFound": "The record to link to no longer exists.",
|
||||
"rateLimited": "Too many requests. Wait a minute and try again.",
|
||||
"browserOnly": "Document archives can only be used in the browser.",
|
||||
"invalidAddress": "Enter the full address, starting with https:// or http://.",
|
||||
"organizationRequired": "Enter the Papra organization id.",
|
||||
"tokenRequired": "Enter the API token.",
|
||||
"fileTooLarge": "The file is larger than HealthLog accepts.",
|
||||
"quotaExceeded": "Your document storage is full.",
|
||||
"fileType": "HealthLog does not accept this file type.",
|
||||
"uploadLimit": "The hourly upload limit is reached. Try again later.",
|
||||
"uploadBusy": "Other uploads are still running. Try again in a moment.",
|
||||
"moduleDisabled": "Documents are switched off in Settings.",
|
||||
"generic": "Something went wrong. Try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -6698,6 +6698,32 @@
|
||||
"showChecklist": "Mostrar la lista de nuevo",
|
||||
"checklistShown": "La lista vuelve a estar en el panel.",
|
||||
"checklistNothingHidden": "La lista no estaba oculta; se abre en el panel."
|
||||
},
|
||||
"documentSources": {
|
||||
"title": "Archivos de documentos",
|
||||
"description": "Busca en Paperless-ngx o Papra desde HealthLog e importa los documentos que elijas.",
|
||||
"explainer": "Aparece un botón de importar allí donde vinculas documentos: en visitas, vacunas y afecciones, y en Documentos. HealthLog guarda su propia copia cifrada de cada documento importado y solo consulta tu archivo cuando buscas o importas.",
|
||||
"systemLabel": "Archivo",
|
||||
"statusConnected": "Conectado",
|
||||
"statusNotConnected": "No conectado",
|
||||
"connectedSince": "Conectado, última comprobación el {date}",
|
||||
"originRemoved": "Esta dirección ya no está en la lista de archivos permitidos del servidor. Consulta a la administración o guarda otra dirección.",
|
||||
"baseUrl": "Dirección",
|
||||
"allowedOrigins": "Permitido en este servidor: {origins}",
|
||||
"organizationId": "Id de organización",
|
||||
"organizationHelp": "La parte que sigue a /organizations/ en la barra de direcciones de Papra.",
|
||||
"token": "Token de la API",
|
||||
"tokenSaved": "Guardado. Déjalo vacío para conservarlo.",
|
||||
"tokenHelpPaperless": "En Paperless-ngx: Mi perfil, token de la API.",
|
||||
"tokenHelpPapra": "En Papra: una clave de API con documents:read y tags:read.",
|
||||
"save": "Guardar y probar",
|
||||
"saving": "Probando…",
|
||||
"saved": "Conectado. El archivo respondió.",
|
||||
"test": "Probar",
|
||||
"testOk": "El archivo respondió en {latency} ms.",
|
||||
"disconnect": "Desconectar",
|
||||
"disconnectTitle": "¿Desconectar {name}?",
|
||||
"disconnectDescription": "HealthLog olvida la dirección y el token. Los documentos ya importados se quedan en HealthLog."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -10147,6 +10173,65 @@
|
||||
"body": "Leer con IA envía esto a tu servicio de IA fuera de este servidor. ¿Permitir que HealthLog envíe documentos, informes de laboratorio y descripciones de medicamentos para leerlos?",
|
||||
"grant": "Permitir lectura",
|
||||
"error": "No se pudo guardar el consentimiento. Inténtalo de nuevo."
|
||||
},
|
||||
"sourcePicker": {
|
||||
"open": "Importar desde archivo",
|
||||
"openNamed": "Importar desde {name}",
|
||||
"title": "Importar desde {name}",
|
||||
"description": "Busca por nombre y marca los documentos que HealthLog debe copiar.",
|
||||
"sourceLabel": "Archivo",
|
||||
"searchLabel": "Nombre del documento",
|
||||
"searchPlaceholder": "Buscar por nombre",
|
||||
"tagLabel": "Etiqueta",
|
||||
"tagAny": "Cualquier etiqueta",
|
||||
"fromLabel": "Desde",
|
||||
"toLabel": "Hasta",
|
||||
"clearFilters": "Quitar etiqueta y fechas",
|
||||
"rangeInvalid": "La fecha final es anterior a la inicial.",
|
||||
"empty": "{name} no tiene documentos que mostrar.",
|
||||
"noMatch": "Ningún documento coincide.",
|
||||
"loadMore": "Cargar más",
|
||||
"stateImported": "En HealthLog",
|
||||
"stateDeleted": "Eliminado en HealthLog",
|
||||
"selectedCount": "{count} de un máximo de {max} seleccionados",
|
||||
"importAction": "Importar {count}",
|
||||
"importing": "Importando, un documento tras otro…",
|
||||
"done": "Hecho",
|
||||
"summary": "Importados: {imported}. Ya estaban: {present}. No importados: {failed}.",
|
||||
"stopped": "Detenido: {reason}",
|
||||
"result": {
|
||||
"waiting": "En espera",
|
||||
"working": "Importando…",
|
||||
"imported": "Importado",
|
||||
"importedLinked": "Importado y vinculado",
|
||||
"duplicate": "Ya está en HealthLog",
|
||||
"duplicateLinked": "Ya está en HealthLog, ahora vinculado",
|
||||
"deleted": "Eliminado en HealthLog, se deja así"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Los archivos de documentos no están habilitados en este servidor.",
|
||||
"notConnected": "Este archivo no está conectado. Conéctalo en Ajustes, Integraciones.",
|
||||
"originNotAllowed": "Esta dirección no está en la lista de archivos permitidos del servidor. Pide a la administración que la añada.",
|
||||
"unreachable": "No se pudo acceder al archivo. Comprueba que está en marcha y que la dirección es correcta.",
|
||||
"redirected": "El archivo redirigió la solicitud. Introduce la dirección a la que redirige.",
|
||||
"authRefused": "El archivo rechazó el token. Comprueba el token y sus permisos.",
|
||||
"versionTooOld": "El archivo es demasiado antiguo. Se necesita Paperless-ngx 2.16 o posterior.",
|
||||
"badResponse": "No se pudo leer la respuesta del archivo. Comprueba que la dirección apunta al propio archivo.",
|
||||
"notFound": "El archivo no tiene ese documento o esa organización.",
|
||||
"linkTargetNotFound": "El registro que se iba a vincular ya no existe.",
|
||||
"rateLimited": "Demasiadas solicitudes. Espera un minuto y vuelve a intentarlo.",
|
||||
"browserOnly": "Los archivos de documentos solo se pueden usar en el navegador.",
|
||||
"invalidAddress": "Introduce la dirección completa, empezando por https:// o http://.",
|
||||
"organizationRequired": "Introduce el id de organización de Papra.",
|
||||
"tokenRequired": "Introduce el token de la API.",
|
||||
"fileTooLarge": "El archivo supera el tamaño que acepta HealthLog.",
|
||||
"quotaExceeded": "Tu almacenamiento de documentos está lleno.",
|
||||
"fileType": "HealthLog no acepta este tipo de archivo.",
|
||||
"uploadLimit": "Se alcanzó el límite de subidas por hora. Inténtalo más tarde.",
|
||||
"uploadBusy": "Aún hay otras subidas en curso. Inténtalo en un momento.",
|
||||
"moduleDisabled": "Los documentos están desactivados en Ajustes.",
|
||||
"generic": "Algo salió mal. Inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -6698,6 +6698,32 @@
|
||||
"showChecklist": "Réafficher la liste de départ",
|
||||
"checklistShown": "La liste de départ est de retour sur le tableau de bord.",
|
||||
"checklistNothingHidden": "La liste n'était pas masquée ; elle s'ouvre sur le tableau de bord."
|
||||
},
|
||||
"documentSources": {
|
||||
"title": "Archives de documents",
|
||||
"description": "Cherchez dans Paperless-ngx ou Papra depuis HealthLog et importez les documents choisis.",
|
||||
"explainer": "Un bouton d'import apparaît partout où vous liez des documents : consultations, vaccinations, affections et Documents. HealthLog garde sa propre copie chiffrée de chaque document importé et n'interroge votre archive que lorsque vous cherchez ou importez.",
|
||||
"systemLabel": "Archive",
|
||||
"statusConnected": "Connecté",
|
||||
"statusNotConnected": "Non connecté",
|
||||
"connectedSince": "Connecté, dernière vérification le {date}",
|
||||
"originRemoved": "Cette adresse ne figure plus dans la liste des archives autorisées du serveur. Voyez avec l'administration ou enregistrez une autre adresse.",
|
||||
"baseUrl": "Adresse",
|
||||
"allowedOrigins": "Autorisé sur ce serveur : {origins}",
|
||||
"organizationId": "Identifiant d'organisation",
|
||||
"organizationHelp": "La partie après /organizations/ dans la barre d'adresse de Papra.",
|
||||
"token": "Jeton d'API",
|
||||
"tokenSaved": "Enregistré. Laissez vide pour le conserver.",
|
||||
"tokenHelpPaperless": "Dans Paperless-ngx : Mon profil, jeton d'API.",
|
||||
"tokenHelpPapra": "Dans Papra : une clé d'API avec documents:read et tags:read.",
|
||||
"save": "Enregistrer et tester",
|
||||
"saving": "Test en cours…",
|
||||
"saved": "Connecté. L'archive a répondu.",
|
||||
"test": "Tester",
|
||||
"testOk": "L'archive a répondu en {latency} ms.",
|
||||
"disconnect": "Déconnecter",
|
||||
"disconnectTitle": "Déconnecter {name} ?",
|
||||
"disconnectDescription": "HealthLog oublie l'adresse et le jeton. Les documents déjà importés restent dans HealthLog."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -10147,6 +10173,65 @@
|
||||
"body": "La lecture par IA envoie ceci à votre service d'IA hors de ce serveur. Autoriser HealthLog à envoyer des documents, des résultats d'analyses et des descriptions de médicaments pour lecture ?",
|
||||
"grant": "Autoriser la lecture",
|
||||
"error": "Le consentement n'a pas pu être enregistré. Veuillez réessayer."
|
||||
},
|
||||
"sourcePicker": {
|
||||
"open": "Importer depuis une archive",
|
||||
"openNamed": "Importer depuis {name}",
|
||||
"title": "Importer depuis {name}",
|
||||
"description": "Cherchez par nom, puis cochez les documents que HealthLog doit copier.",
|
||||
"sourceLabel": "Archive",
|
||||
"searchLabel": "Nom du document",
|
||||
"searchPlaceholder": "Chercher par nom",
|
||||
"tagLabel": "Étiquette",
|
||||
"tagAny": "Toutes les étiquettes",
|
||||
"fromLabel": "Du",
|
||||
"toLabel": "Au",
|
||||
"clearFilters": "Effacer l'étiquette et les dates",
|
||||
"rangeInvalid": "La date de fin précède la date de début.",
|
||||
"empty": "{name} n'a aucun document à afficher.",
|
||||
"noMatch": "Aucun document ne correspond.",
|
||||
"loadMore": "Charger plus",
|
||||
"stateImported": "Dans HealthLog",
|
||||
"stateDeleted": "Supprimé dans HealthLog",
|
||||
"selectedCount": "{count} sélectionnés sur {max} au plus",
|
||||
"importAction": "Importer {count}",
|
||||
"importing": "Import en cours, un document après l'autre…",
|
||||
"done": "Terminé",
|
||||
"summary": "Importés : {imported}. Déjà présents : {present}. Non importés : {failed}.",
|
||||
"stopped": "Arrêté : {reason}",
|
||||
"result": {
|
||||
"waiting": "En attente",
|
||||
"working": "Import en cours…",
|
||||
"imported": "Importé",
|
||||
"importedLinked": "Importé et lié",
|
||||
"duplicate": "Déjà dans HealthLog",
|
||||
"duplicateLinked": "Déjà dans HealthLog, désormais lié",
|
||||
"deleted": "Supprimé dans HealthLog, laissé tel quel"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Les archives de documents ne sont pas activées sur ce serveur.",
|
||||
"notConnected": "Cette archive n'est pas connectée. Connectez-la dans Réglages, Intégrations.",
|
||||
"originNotAllowed": "Cette adresse ne figure pas dans la liste des archives autorisées du serveur. Demandez à l'administration de l'ajouter.",
|
||||
"unreachable": "L'archive est injoignable. Vérifiez qu'elle fonctionne et que l'adresse est juste.",
|
||||
"redirected": "L'archive a redirigé la requête. Saisissez l'adresse vers laquelle elle redirige.",
|
||||
"authRefused": "L'archive a refusé le jeton. Vérifiez le jeton et ses droits.",
|
||||
"versionTooOld": "L'archive est trop ancienne. Paperless-ngx 2.16 ou plus récent est nécessaire.",
|
||||
"badResponse": "La réponse de l'archive est illisible. Vérifiez que l'adresse pointe vers l'archive elle-même.",
|
||||
"notFound": "L'archive ne connaît pas ce document ou cette organisation.",
|
||||
"linkTargetNotFound": "L'élément auquel lier le document n'existe plus.",
|
||||
"rateLimited": "Trop de requêtes. Patientez une minute et réessayez.",
|
||||
"browserOnly": "Les archives de documents ne s'utilisent que dans le navigateur.",
|
||||
"invalidAddress": "Saisissez l'adresse complète, commençant par https:// ou http://.",
|
||||
"organizationRequired": "Saisissez l'identifiant d'organisation Papra.",
|
||||
"tokenRequired": "Saisissez le jeton d'API.",
|
||||
"fileTooLarge": "Le fichier dépasse la taille acceptée par HealthLog.",
|
||||
"quotaExceeded": "Votre espace de documents est plein.",
|
||||
"fileType": "HealthLog n'accepte pas ce type de fichier.",
|
||||
"uploadLimit": "La limite horaire d'envois est atteinte. Réessayez plus tard.",
|
||||
"uploadBusy": "D'autres envois sont en cours. Réessayez dans un instant.",
|
||||
"moduleDisabled": "Les documents sont désactivés dans les réglages.",
|
||||
"generic": "Un problème est survenu. Réessayez."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -6698,6 +6698,32 @@
|
||||
"showChecklist": "Mostra di nuovo la lista",
|
||||
"checklistShown": "La lista è di nuovo nella dashboard.",
|
||||
"checklistNothingHidden": "La lista non era nascosta; si apre nella dashboard."
|
||||
},
|
||||
"documentSources": {
|
||||
"title": "Archivi di documenti",
|
||||
"description": "Cerca in Paperless-ngx o Papra da HealthLog e importa i documenti che scegli.",
|
||||
"explainer": "Un pulsante di importazione compare ovunque colleghi documenti: nelle visite, nelle vaccinazioni, nelle condizioni e in Documenti. HealthLog conserva una propria copia cifrata di ogni documento importato e interroga il tuo archivio solo quando cerchi o importi.",
|
||||
"systemLabel": "Archivio",
|
||||
"statusConnected": "Collegato",
|
||||
"statusNotConnected": "Non collegato",
|
||||
"connectedSince": "Collegato, ultimo controllo il {date}",
|
||||
"originRemoved": "Questo indirizzo non è più nell'elenco degli archivi consentiti dal server. Chiedi all'amministrazione o salva un altro indirizzo.",
|
||||
"baseUrl": "Indirizzo",
|
||||
"allowedOrigins": "Consentito su questo server: {origins}",
|
||||
"organizationId": "Id organizzazione",
|
||||
"organizationHelp": "La parte dopo /organizations/ nella barra degli indirizzi di Papra.",
|
||||
"token": "Token API",
|
||||
"tokenSaved": "Salvato. Lascia vuoto per mantenerlo.",
|
||||
"tokenHelpPaperless": "In Paperless-ngx: Il mio profilo, token API.",
|
||||
"tokenHelpPapra": "In Papra: una chiave API con documents:read e tags:read.",
|
||||
"save": "Salva e prova",
|
||||
"saving": "Prova in corso…",
|
||||
"saved": "Collegato. L'archivio ha risposto.",
|
||||
"test": "Prova",
|
||||
"testOk": "L'archivio ha risposto in {latency} ms.",
|
||||
"disconnect": "Scollega",
|
||||
"disconnectTitle": "Scollegare {name}?",
|
||||
"disconnectDescription": "HealthLog dimentica indirizzo e token. I documenti già importati restano in HealthLog."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -10147,6 +10173,65 @@
|
||||
"body": "La lettura con IA invia questo contenuto al tuo servizio di IA fuori da questo server. Consentire a HealthLog di inviare documenti, referti di laboratorio e descrizioni di farmaci per la lettura?",
|
||||
"grant": "Consenti lettura",
|
||||
"error": "Non è stato possibile salvare il consenso. Riprova."
|
||||
},
|
||||
"sourcePicker": {
|
||||
"open": "Importa da archivio",
|
||||
"openNamed": "Importa da {name}",
|
||||
"title": "Importa da {name}",
|
||||
"description": "Cerca per nome, poi spunta i documenti che HealthLog deve copiare.",
|
||||
"sourceLabel": "Archivio",
|
||||
"searchLabel": "Nome del documento",
|
||||
"searchPlaceholder": "Cerca per nome",
|
||||
"tagLabel": "Etichetta",
|
||||
"tagAny": "Qualsiasi etichetta",
|
||||
"fromLabel": "Dal",
|
||||
"toLabel": "Al",
|
||||
"clearFilters": "Azzera etichetta e date",
|
||||
"rangeInvalid": "La data finale è precedente a quella iniziale.",
|
||||
"empty": "{name} non ha documenti da mostrare.",
|
||||
"noMatch": "Nessun documento corrisponde.",
|
||||
"loadMore": "Carica altri",
|
||||
"stateImported": "Già in HealthLog",
|
||||
"stateDeleted": "Eliminato in HealthLog",
|
||||
"selectedCount": "{count} selezionati su un massimo di {max}",
|
||||
"importAction": "Importa {count}",
|
||||
"importing": "Importazione in corso, un documento alla volta…",
|
||||
"done": "Fatto",
|
||||
"summary": "Importati: {imported}. Già presenti: {present}. Non importati: {failed}.",
|
||||
"stopped": "Interrotto: {reason}",
|
||||
"result": {
|
||||
"waiting": "In attesa",
|
||||
"working": "Importazione…",
|
||||
"imported": "Importato",
|
||||
"importedLinked": "Importato e collegato",
|
||||
"duplicate": "Già in HealthLog",
|
||||
"duplicateLinked": "Già in HealthLog, ora collegato",
|
||||
"deleted": "Eliminato in HealthLog, lasciato così"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Gli archivi di documenti non sono abilitati su questo server.",
|
||||
"notConnected": "Questo archivio non è collegato. Collegalo in Impostazioni, Integrazioni.",
|
||||
"originNotAllowed": "Questo indirizzo non è nell'elenco degli archivi consentiti dal server. Chiedi all'amministrazione di aggiungerlo.",
|
||||
"unreachable": "L'archivio non è raggiungibile. Verifica che sia in funzione e che l'indirizzo sia corretto.",
|
||||
"redirected": "L'archivio ha reindirizzato la richiesta. Inserisci l'indirizzo verso cui reindirizza.",
|
||||
"authRefused": "L'archivio ha rifiutato il token. Controlla il token e i suoi permessi.",
|
||||
"versionTooOld": "L'archivio è troppo vecchio. Serve Paperless-ngx 2.16 o successivo.",
|
||||
"badResponse": "Non è stato possibile leggere la risposta dell'archivio. Verifica che l'indirizzo punti all'archivio stesso.",
|
||||
"notFound": "L'archivio non ha questo documento o questa organizzazione.",
|
||||
"linkTargetNotFound": "L'elemento da collegare non esiste più.",
|
||||
"rateLimited": "Troppe richieste. Attendi un minuto e riprova.",
|
||||
"browserOnly": "Gli archivi di documenti si usano solo nel browser.",
|
||||
"invalidAddress": "Inserisci l'indirizzo completo, che inizia con https:// o http://.",
|
||||
"organizationRequired": "Inserisci l'id dell'organizzazione Papra.",
|
||||
"tokenRequired": "Inserisci il token API.",
|
||||
"fileTooLarge": "Il file è più grande di quanto HealthLog accetti.",
|
||||
"quotaExceeded": "Lo spazio per i documenti è pieno.",
|
||||
"fileType": "HealthLog non accetta questo tipo di file.",
|
||||
"uploadLimit": "È stato raggiunto il limite orario di caricamenti. Riprova più tardi.",
|
||||
"uploadBusy": "Altri caricamenti sono ancora in corso. Riprova tra un momento.",
|
||||
"moduleDisabled": "I documenti sono disattivati nelle impostazioni.",
|
||||
"generic": "Qualcosa è andato storto. Riprova."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -6698,6 +6698,32 @@
|
||||
"showChecklist": "체크리스트 다시 표시",
|
||||
"checklistShown": "체크리스트가 대시보드에 다시 표시됩니다.",
|
||||
"checklistNothingHidden": "체크리스트는 숨겨져 있지 않았습니다. 대시보드에서 열립니다."
|
||||
},
|
||||
"documentSources": {
|
||||
"title": "문서 보관함",
|
||||
"description": "HealthLog에서 Paperless-ngx나 Papra를 검색하고 선택한 문서를 가져옵니다.",
|
||||
"explainer": "문서를 연결하는 곳마다 가져오기 버튼이 나타납니다: 진료, 예방접종, 질환, 그리고 문서 페이지. HealthLog는 가져온 문서마다 자체 암호화 사본을 보관하며, 검색하거나 가져올 때만 보관함에 요청합니다.",
|
||||
"systemLabel": "보관함",
|
||||
"statusConnected": "연결됨",
|
||||
"statusNotConnected": "연결 안 됨",
|
||||
"connectedSince": "연결됨, 마지막 확인 {date}",
|
||||
"originRemoved": "이 주소는 더 이상 서버의 허용된 보관함 목록에 없습니다. 관리자에게 문의하거나 다른 주소를 저장하세요.",
|
||||
"baseUrl": "주소",
|
||||
"allowedOrigins": "이 서버에서 허용됨: {origins}",
|
||||
"organizationId": "조직 ID",
|
||||
"organizationHelp": "Papra 주소 표시줄에서 /organizations/ 다음 부분입니다.",
|
||||
"token": "API 토큰",
|
||||
"tokenSaved": "저장됨. 유지하려면 비워 두세요.",
|
||||
"tokenHelpPaperless": "Paperless-ngx: 내 프로필의 API 토큰.",
|
||||
"tokenHelpPapra": "Papra: documents:read와 tags:read 권한이 있는 API 키.",
|
||||
"save": "저장하고 테스트",
|
||||
"saving": "테스트 중…",
|
||||
"saved": "연결되었습니다. 보관함이 응답했습니다.",
|
||||
"test": "테스트",
|
||||
"testOk": "보관함이 {latency}ms 만에 응답했습니다.",
|
||||
"disconnect": "연결 해제",
|
||||
"disconnectTitle": "{name} 연결을 해제할까요?",
|
||||
"disconnectDescription": "HealthLog가 주소와 토큰을 잊습니다. 이미 가져온 문서는 HealthLog에 남습니다."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -10147,6 +10173,65 @@
|
||||
"body": "AI로 읽으면 이 내용이 이 서버 밖의 AI 서비스로 전송됩니다. HealthLog가 문서, 검사 결과지, 약 설명을 읽기용으로 보내도록 허용하시겠습니까?",
|
||||
"grant": "읽기 허용",
|
||||
"error": "동의를 저장하지 못했습니다. 다시 시도해 주세요."
|
||||
},
|
||||
"sourcePicker": {
|
||||
"open": "보관함에서 가져오기",
|
||||
"openNamed": "{name}에서 가져오기",
|
||||
"title": "{name}에서 가져오기",
|
||||
"description": "이름으로 검색한 뒤 HealthLog로 복사할 문서를 선택하세요.",
|
||||
"sourceLabel": "보관함",
|
||||
"searchLabel": "문서 이름",
|
||||
"searchPlaceholder": "이름으로 검색",
|
||||
"tagLabel": "태그",
|
||||
"tagAny": "모든 태그",
|
||||
"fromLabel": "시작일",
|
||||
"toLabel": "종료일",
|
||||
"clearFilters": "태그와 날짜 지우기",
|
||||
"rangeInvalid": "종료일이 시작일보다 앞섭니다.",
|
||||
"empty": "{name}에 표시할 문서가 없습니다.",
|
||||
"noMatch": "일치하는 문서가 없습니다.",
|
||||
"loadMore": "더 불러오기",
|
||||
"stateImported": "HealthLog에 있음",
|
||||
"stateDeleted": "HealthLog에서 삭제됨",
|
||||
"selectedCount": "최대 {max}개 중 {count}개 선택",
|
||||
"importAction": "{count}개 가져오기",
|
||||
"importing": "문서를 하나씩 가져오는 중…",
|
||||
"done": "완료",
|
||||
"summary": "가져옴: {imported}. 이미 있음: {present}. 가져오지 못함: {failed}.",
|
||||
"stopped": "중단됨: {reason}",
|
||||
"result": {
|
||||
"waiting": "대기 중",
|
||||
"working": "가져오는 중…",
|
||||
"imported": "가져옴",
|
||||
"importedLinked": "가져오고 연결함",
|
||||
"duplicate": "이미 HealthLog에 있음",
|
||||
"duplicateLinked": "이미 HealthLog에 있음, 이제 연결됨",
|
||||
"deleted": "HealthLog에서 삭제됨, 그대로 둠"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "이 서버에서는 문서 보관함이 활성화되어 있지 않습니다.",
|
||||
"notConnected": "이 보관함은 연결되어 있지 않습니다. 설정의 통합에서 연결하세요.",
|
||||
"originNotAllowed": "이 주소는 서버의 허용된 보관함 목록에 없습니다. 관리자에게 추가를 요청하세요.",
|
||||
"unreachable": "보관함에 연결할 수 없습니다. 실행 중인지, 주소가 맞는지 확인하세요.",
|
||||
"redirected": "보관함이 요청을 리디렉션했습니다. 리디렉션되는 주소를 입력하세요.",
|
||||
"authRefused": "보관함이 토큰을 거부했습니다. 토큰과 권한을 확인하세요.",
|
||||
"versionTooOld": "보관함 버전이 너무 오래되었습니다. Paperless-ngx 2.16 이상이 필요합니다.",
|
||||
"badResponse": "보관함의 응답을 읽을 수 없습니다. 주소가 보관함 자체를 가리키는지 확인하세요.",
|
||||
"notFound": "보관함에 해당 문서나 조직이 없습니다.",
|
||||
"linkTargetNotFound": "연결할 기록이 더 이상 존재하지 않습니다.",
|
||||
"rateLimited": "요청이 너무 많습니다. 1분 후에 다시 시도하세요.",
|
||||
"browserOnly": "문서 보관함은 브라우저에서만 사용할 수 있습니다.",
|
||||
"invalidAddress": "https:// 또는 http://로 시작하는 전체 주소를 입력하세요.",
|
||||
"organizationRequired": "Papra 조직 ID를 입력하세요.",
|
||||
"tokenRequired": "API 토큰을 입력하세요.",
|
||||
"fileTooLarge": "파일이 HealthLog가 허용하는 크기보다 큽니다.",
|
||||
"quotaExceeded": "문서 저장 공간이 가득 찼습니다.",
|
||||
"fileType": "HealthLog는 이 파일 형식을 받지 않습니다.",
|
||||
"uploadLimit": "시간당 업로드 한도에 도달했습니다. 나중에 다시 시도하세요.",
|
||||
"uploadBusy": "다른 업로드가 아직 진행 중입니다. 잠시 후 다시 시도하세요.",
|
||||
"moduleDisabled": "설정에서 문서 기능이 꺼져 있습니다.",
|
||||
"generic": "문제가 발생했습니다. 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -6698,6 +6698,32 @@
|
||||
"showChecklist": "Pokaż listę ponownie",
|
||||
"checklistShown": "Lista wróciła na panel.",
|
||||
"checklistNothingHidden": "Lista nie była ukryta; otwiera się na panelu."
|
||||
},
|
||||
"documentSources": {
|
||||
"title": "Archiwa dokumentów",
|
||||
"description": "Przeszukuj Paperless-ngx lub Papra z poziomu HealthLog i importuj wybrane dokumenty.",
|
||||
"explainer": "Przycisk importu pojawia się wszędzie tam, gdzie wiążesz dokumenty: przy wizytach, szczepieniach i schorzeniach oraz w Dokumentach. HealthLog przechowuje własną zaszyfrowaną kopię każdego zaimportowanego dokumentu i pyta archiwum tylko wtedy, gdy szukasz lub importujesz.",
|
||||
"systemLabel": "Archiwum",
|
||||
"statusConnected": "Połączono",
|
||||
"statusNotConnected": "Nie połączono",
|
||||
"connectedSince": "Połączono, ostatnio sprawdzono {date}",
|
||||
"originRemoved": "Tego adresu nie ma już na liście dozwolonych archiwów serwera. Zapytaj administrację albo zapisz inny adres.",
|
||||
"baseUrl": "Adres",
|
||||
"allowedOrigins": "Dozwolone na tym serwerze: {origins}",
|
||||
"organizationId": "Identyfikator organizacji",
|
||||
"organizationHelp": "Część po /organizations/ w pasku adresu Papra.",
|
||||
"token": "Token API",
|
||||
"tokenSaved": "Zapisano. Zostaw puste, aby go zachować.",
|
||||
"tokenHelpPaperless": "W Paperless-ngx: Mój profil, token API.",
|
||||
"tokenHelpPapra": "W Papra: klucz API z uprawnieniami documents:read i tags:read.",
|
||||
"save": "Zapisz i sprawdź",
|
||||
"saving": "Sprawdzanie…",
|
||||
"saved": "Połączono. Archiwum odpowiedziało.",
|
||||
"test": "Sprawdź",
|
||||
"testOk": "Archiwum odpowiedziało w {latency} ms.",
|
||||
"disconnect": "Rozłącz",
|
||||
"disconnectTitle": "Rozłączyć {name}?",
|
||||
"disconnectDescription": "HealthLog zapomina adres i token. Zaimportowane już dokumenty zostają w HealthLog."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -10147,6 +10173,65 @@
|
||||
"body": "Odczyt przez AI wysyła to do Twojej usługi AI poza tym serwerem. Czy HealthLog może wysyłać dokumenty, wyniki badań i opisy leków do odczytu?",
|
||||
"grant": "Zezwól na odczyt",
|
||||
"error": "Nie udało się zapisać zgody. Spróbuj ponownie."
|
||||
},
|
||||
"sourcePicker": {
|
||||
"open": "Importuj z archiwum",
|
||||
"openNamed": "Importuj z {name}",
|
||||
"title": "Import z {name}",
|
||||
"description": "Szukaj po nazwie, a potem zaznacz dokumenty, które HealthLog ma skopiować.",
|
||||
"sourceLabel": "Archiwum",
|
||||
"searchLabel": "Nazwa dokumentu",
|
||||
"searchPlaceholder": "Szukaj po nazwie",
|
||||
"tagLabel": "Tag",
|
||||
"tagAny": "Dowolny tag",
|
||||
"fromLabel": "Od",
|
||||
"toLabel": "Do",
|
||||
"clearFilters": "Wyczyść tag i daty",
|
||||
"rangeInvalid": "Data końcowa jest wcześniejsza niż początkowa.",
|
||||
"empty": "{name} nie ma dokumentów do pokazania.",
|
||||
"noMatch": "Brak pasujących dokumentów.",
|
||||
"loadMore": "Wczytaj więcej",
|
||||
"stateImported": "W HealthLog",
|
||||
"stateDeleted": "Usunięty w HealthLog",
|
||||
"selectedCount": "Wybrano {count} z maksymalnie {max}",
|
||||
"importAction": "Importuj ({count})",
|
||||
"importing": "Importowanie, dokument po dokumencie…",
|
||||
"done": "Gotowe",
|
||||
"summary": "Zaimportowane: {imported}. Już były: {present}. Niezaimportowane: {failed}.",
|
||||
"stopped": "Zatrzymano: {reason}",
|
||||
"result": {
|
||||
"waiting": "Czeka",
|
||||
"working": "Importowanie…",
|
||||
"imported": "Zaimportowano",
|
||||
"importedLinked": "Zaimportowano i powiązano",
|
||||
"duplicate": "Już jest w HealthLog",
|
||||
"duplicateLinked": "Już jest w HealthLog, teraz powiązany",
|
||||
"deleted": "Usunięty w HealthLog, pozostaje usunięty"
|
||||
},
|
||||
"errors": {
|
||||
"unavailable": "Archiwa dokumentów nie są włączone na tym serwerze.",
|
||||
"notConnected": "To archiwum nie jest połączone. Połącz je w Ustawieniach, Integracje.",
|
||||
"originNotAllowed": "Tego adresu nie ma na liście dozwolonych archiwów serwera. Poproś administrację o jego dodanie.",
|
||||
"unreachable": "Nie udało się połączyć z archiwum. Sprawdź, czy działa i czy adres jest poprawny.",
|
||||
"redirected": "Archiwum przekierowało żądanie. Wpisz adres, na który przekierowuje.",
|
||||
"authRefused": "Archiwum odrzuciło token. Sprawdź token i jego uprawnienia.",
|
||||
"versionTooOld": "Archiwum jest za stare. Potrzebny jest Paperless-ngx 2.16 lub nowszy.",
|
||||
"badResponse": "Nie udało się odczytać odpowiedzi archiwum. Sprawdź, czy adres wskazuje na samo archiwum.",
|
||||
"notFound": "Archiwum nie ma takiego dokumentu ani takiej organizacji.",
|
||||
"linkTargetNotFound": "Wpis, z którym miało nastąpić powiązanie, już nie istnieje.",
|
||||
"rateLimited": "Za dużo żądań. Odczekaj minutę i spróbuj ponownie.",
|
||||
"browserOnly": "Z archiwów dokumentów można korzystać tylko w przeglądarce.",
|
||||
"invalidAddress": "Wpisz pełny adres, zaczynający się od https:// lub http://.",
|
||||
"organizationRequired": "Wpisz identyfikator organizacji Papra.",
|
||||
"tokenRequired": "Wpisz token API.",
|
||||
"fileTooLarge": "Plik jest większy, niż przyjmuje HealthLog.",
|
||||
"quotaExceeded": "Miejsce na dokumenty jest pełne.",
|
||||
"fileType": "HealthLog nie przyjmuje tego typu pliku.",
|
||||
"uploadLimit": "Osiągnięto godzinny limit przesyłania. Spróbuj później.",
|
||||
"uploadBusy": "Trwają jeszcze inne przesyłania. Spróbuj za chwilę.",
|
||||
"moduleDisabled": "Dokumenty są wyłączone w ustawieniach.",
|
||||
"generic": "Coś poszło nie tak. Spróbuj ponownie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -67,6 +67,7 @@ import { DocumentShareSheet } from "./document-share-sheet";
|
||||
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
|
||||
import { DocumentTimeline } from "./document-timeline";
|
||||
import { UploadZone } from "./upload-zone";
|
||||
import { ImportFromSourceButton } from "./sources/import-from-source-button";
|
||||
import { useReindexAll } from "./use-content-index";
|
||||
import { useDocumentUpload } from "./use-document-upload";
|
||||
import { usePageFileDrop } from "./use-page-file-drop";
|
||||
@@ -876,13 +877,17 @@ export function DocumentsView() {
|
||||
description={t("documents.subtitle")}
|
||||
actions={
|
||||
canManageDocuments ? (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.pageUpload")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Renders only with a connected Paperless-ngx or Papra. */}
|
||||
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.pageUpload")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentLinkPicker } from "./document-link-picker";
|
||||
import { ImportFromSourceButton } from "./sources/import-from-source-button";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import { documentDateKey } from "./vault-utils";
|
||||
|
||||
@@ -167,6 +168,9 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
{t("documents.episodeCard.upload")}
|
||||
</Link>
|
||||
</Button>
|
||||
<ImportFromSourceButton
|
||||
link={{ kind: "conditionEpisode", id: episodeId }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{hasMore ? (
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* The document picker's client gates (#1038), pinned via static renders:
|
||||
*
|
||||
* - the import button exists only for a person in their own record with a
|
||||
* usable connection; a delegate, an unset operator list, no connection, or
|
||||
* a connection whose origin was removed leave no button;
|
||||
* - its label names the one connected system, or stays generic for two;
|
||||
* - the settings card renders nothing unless the operator enabled the
|
||||
* picker, lists the allowed origins, and never puts a token in the markup;
|
||||
* - every error code the routes emit has its own sentence, and an unknown
|
||||
* one falls back to the generic sentence rather than a raw key.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
let modules: Record<string, boolean> = { inboundDocuments: true };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
let shared = false;
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
inSharedRecord: shared,
|
||||
canManageDomain: () => !shared,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return { ...actual, apiGet: () => new Promise(() => {}) };
|
||||
});
|
||||
|
||||
import { DocumentSourcesCard } from "@/components/settings/integrations/document-sources-card";
|
||||
import en from "../../../../../messages/en.json";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
DOCUMENT_SOURCE_ERROR_CODES,
|
||||
type DocumentSourcesStatusDto,
|
||||
} from "@/lib/documents/sources/types";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { ImportFromSourceButton } from "../import-from-source-button";
|
||||
import { mergeIds } from "../merge-ids";
|
||||
import { errorCodeOf, sourceErrorMessage } from "../source-errors";
|
||||
|
||||
const PAPERLESS = {
|
||||
system: "PAPERLESS" as const,
|
||||
baseUrl: "http://paperless.lan:8000",
|
||||
organizationId: null,
|
||||
hasToken: true as const,
|
||||
lastVerifiedAt: "2026-09-20T10:00:00.000Z",
|
||||
originAllowed: true,
|
||||
};
|
||||
const PAPRA = {
|
||||
...PAPERLESS,
|
||||
system: "PAPRA" as const,
|
||||
baseUrl: "https://papra.example.com",
|
||||
organizationId: "org_1",
|
||||
};
|
||||
|
||||
function render(node: React.ReactNode, status?: DocumentSourcesStatusDto) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
if (status)
|
||||
queryClient.setQueryData(queryKeys.documentSourcesStatus(), status);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const on = (connections: DocumentSourcesStatusDto["connections"]) => ({
|
||||
available: true,
|
||||
allowedOrigins: ["http://paperless.lan:8000", "https://papra.example.com"],
|
||||
connections,
|
||||
});
|
||||
|
||||
describe("<ImportFromSourceButton>", () => {
|
||||
it("names the one connected system", () => {
|
||||
const html = render(<ImportFromSourceButton />, on([PAPERLESS]));
|
||||
expect(html).toContain('data-slot="document-source-open"');
|
||||
expect(html).toContain("Import from Paperless-ngx");
|
||||
});
|
||||
|
||||
it("stays generic with two", () => {
|
||||
const html = render(<ImportFromSourceButton />, on([PAPERLESS, PAPRA]));
|
||||
expect(html).toContain("Import from archive");
|
||||
});
|
||||
|
||||
it("renders nothing without a usable connection", () => {
|
||||
expect(render(<ImportFromSourceButton />, on([]))).toBe("");
|
||||
expect(
|
||||
render(
|
||||
<ImportFromSourceButton />,
|
||||
on([{ ...PAPERLESS, originAllowed: false }]),
|
||||
),
|
||||
).toBe("");
|
||||
expect(
|
||||
render(<ImportFromSourceButton />, {
|
||||
available: false,
|
||||
allowedOrigins: [],
|
||||
connections: [],
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing for a delegate, even with the owner's data cached", () => {
|
||||
shared = true;
|
||||
try {
|
||||
expect(render(<ImportFromSourceButton />, on([PAPERLESS]))).toBe("");
|
||||
} finally {
|
||||
shared = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders nothing with the documents module off", () => {
|
||||
modules = { inboundDocuments: false };
|
||||
try {
|
||||
expect(render(<ImportFromSourceButton />, on([PAPERLESS]))).toBe("");
|
||||
} finally {
|
||||
modules = { inboundDocuments: true };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentSourcesCard>", () => {
|
||||
it("renders nothing when the operator has not enabled the picker", () => {
|
||||
expect(
|
||||
render(<DocumentSourcesCard />, {
|
||||
available: false,
|
||||
allowedOrigins: [],
|
||||
connections: [],
|
||||
}),
|
||||
).toBe("");
|
||||
expect(render(<DocumentSourcesCard />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing for a delegate", () => {
|
||||
shared = true;
|
||||
try {
|
||||
expect(render(<DocumentSourcesCard />, on([PAPERLESS]))).toBe("");
|
||||
} finally {
|
||||
shared = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the form, the allowed origins and a saved-token placeholder, never a token", () => {
|
||||
const html = render(<DocumentSourcesCard />, on([PAPERLESS]));
|
||||
expect(html).toContain('data-testid="document-sources-card"');
|
||||
expect(html).toContain("Document archives");
|
||||
expect(html).toContain(
|
||||
"Allowed on this server: http://paperless.lan:8000, https://papra.example.com",
|
||||
);
|
||||
expect(html).toContain("Saved. Leave empty to keep it.");
|
||||
expect(html).toContain('value="http://paperless.lan:8000"');
|
||||
// The password field is empty: the server never sends the token back.
|
||||
expect(html).toMatch(/id="document-source-token-paperless"[^>]*value=""/);
|
||||
expect(html).toContain("Disconnect");
|
||||
});
|
||||
|
||||
it("offers no disconnect or test for a system that is not connected", () => {
|
||||
const html = render(<DocumentSourcesCard />, on([]));
|
||||
expect(html).toContain("Not connected");
|
||||
expect(html).not.toContain("Disconnect");
|
||||
expect(html).toContain("Save and test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("source error sentences", () => {
|
||||
const t = (key: string) => {
|
||||
let node: unknown = en;
|
||||
for (const part of key.split(".")) {
|
||||
node = (node as Record<string, unknown> | undefined)?.[part];
|
||||
}
|
||||
return typeof node === "string" ? node : key;
|
||||
};
|
||||
const generic = t("documents.sourcePicker.errors.generic");
|
||||
|
||||
it("has its own sentence for every code the routes emit", () => {
|
||||
const codes = [
|
||||
...DOCUMENT_SOURCE_ERROR_CODES,
|
||||
"documents.sources.browserOnly",
|
||||
"documents.sources.invalidAddress",
|
||||
"documents.sources.organizationRequired",
|
||||
"documents.sources.tokenRequired",
|
||||
"documents.inbound.fileTooLarge",
|
||||
"documents.inbound.quotaExceeded",
|
||||
"documents.inbound.fileType",
|
||||
"documents.inbound.rateLimited",
|
||||
"documents.inbound.uploadBusy",
|
||||
"module.disabled",
|
||||
];
|
||||
const sentences = new Set<string>();
|
||||
for (const code of codes) {
|
||||
const sentence = sourceErrorMessage(t, code);
|
||||
expect(sentence, code).not.toBe(generic);
|
||||
expect(sentence, code).not.toContain("documents.sourcePicker");
|
||||
sentences.add(sentence);
|
||||
}
|
||||
expect(sentences.size).toBe(codes.length);
|
||||
expect(sourceErrorMessage(t, "something.else")).toBe(generic);
|
||||
expect(sourceErrorMessage(t, undefined)).toBe(generic);
|
||||
});
|
||||
|
||||
it("reads the code off a failed request", () => {
|
||||
expect(
|
||||
errorCodeOf(
|
||||
new ApiError("x", 502, { errorCode: "documents.sources.authRefused" }),
|
||||
),
|
||||
).toBe("documents.sources.authRefused");
|
||||
expect(errorCodeOf(new Error("x"))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeIds", () => {
|
||||
it("adds imported ids to a form's selection once, in order", () => {
|
||||
expect(mergeIds(["a", "b"], ["b", "c", "c"])).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,654 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The document picker (#1038): search a connected Paperless-ngx or Papra by
|
||||
* document name, narrow by tag and date, tick the documents to bring over,
|
||||
* import them.
|
||||
*
|
||||
* Name first, because that is how the person looks for a letter: the search
|
||||
* field opens focused and debounces into the source's own name search. Tag and
|
||||
* date are filters beside it, not the entry point. Each result says what
|
||||
* HealthLog already holds under that source key: already imported, deleted
|
||||
* here (not offered again: a deletion was a decision), or new.
|
||||
*
|
||||
* The import runs one document per request, in order, so every row can show
|
||||
* its own progress and result, and one failure does not hide the others. A
|
||||
* refusal that will answer every following document the same way (the hourly
|
||||
* upload allowance, a full vault, a refused token) stops the run and says so.
|
||||
*
|
||||
* Where it was opened from decides what an import does besides storing:
|
||||
*
|
||||
* - `link`: the record already exists (a condition's page), so the server
|
||||
* files the document against it as part of the import;
|
||||
* - `onImported`: a form that owns its selection (a visit, a vaccination)
|
||||
* gets the new ids and adds them itself. Linking on the server there would
|
||||
* be undone by the form's own save, which replaces its links.
|
||||
*
|
||||
* With either, a document already in HealthLog can be picked too: it is not
|
||||
* stored twice, it is linked or handed to the form.
|
||||
*/
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
Loader2,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
DOCUMENT_PICKER_MAX_SELECTION,
|
||||
slugForSystem,
|
||||
type DocumentImportOutcome,
|
||||
type DocumentPickerLinkKind,
|
||||
type DocumentPickerSystem,
|
||||
type DocumentSourceImportDto,
|
||||
type DocumentSourceResultDto,
|
||||
type DocumentSourceSearchDto,
|
||||
type DocumentSourceTagDto,
|
||||
} from "@/lib/documents/sources/types";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { formatBytes } from "../vault-utils";
|
||||
import {
|
||||
errorCodeOf,
|
||||
isStoppingError,
|
||||
sourceErrorMessage,
|
||||
} from "./source-errors";
|
||||
import { systemName } from "./use-document-sources";
|
||||
|
||||
export interface DocumentPickerLink {
|
||||
kind: DocumentPickerLinkKind;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DocumentSourcePickerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Connected systems, in the order the switch shows them. */
|
||||
systems: DocumentPickerSystem[];
|
||||
/** File the imported documents against this existing record. */
|
||||
link?: DocumentPickerLink;
|
||||
/** Document type for everything imported (default OTHER). */
|
||||
kind?: InboundDocumentKindValue;
|
||||
/** Hand the vault ids to a form that links them on its own save. */
|
||||
onImported?: (documentIds: string[]) => void;
|
||||
}
|
||||
|
||||
type RowRun =
|
||||
| { state: "waiting" }
|
||||
| { state: "working" }
|
||||
| { state: DocumentImportOutcome; linked: boolean }
|
||||
| { state: "failed"; message: string };
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const IMPORT_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function DocumentSourcePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
systems,
|
||||
link,
|
||||
kind,
|
||||
onImported,
|
||||
}: DocumentSourcePickerProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [system, setSystem] = useState<DocumentPickerSystem | null>(null);
|
||||
const active =
|
||||
system && systems.includes(system) ? system : (systems[0] ?? null);
|
||||
const slug = active ? slugForSystem(active) : null;
|
||||
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [tag, setTag] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [selected, setSelected] = useState<Map<string, string>>(new Map());
|
||||
const [runs, setRuns] = useState<Map<string, RowRun>>(new Map());
|
||||
const [running, setRunning] = useState(false);
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [stopMessage, setStopMessage] = useState<string | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearchDraft(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(
|
||||
() => setQ(value.trim()),
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setSearchDraft("");
|
||||
setQ("");
|
||||
setTag("");
|
||||
setFrom("");
|
||||
setTo("");
|
||||
setSelected(new Map());
|
||||
setRuns(new Map());
|
||||
setFinished(false);
|
||||
setStopMessage(null);
|
||||
};
|
||||
|
||||
const switchSystem = (next: DocumentPickerSystem) => {
|
||||
if (next === active || running) return;
|
||||
reset();
|
||||
setSystem(next);
|
||||
};
|
||||
|
||||
const tags = useQuery({
|
||||
queryKey: queryKeys.documentSourceTags(slug ?? ""),
|
||||
enabled: open && slug !== null,
|
||||
queryFn: () =>
|
||||
apiGet<{ tags: DocumentSourceTagDto[] }>(
|
||||
`/api/documents/sources/${slug}/tags`,
|
||||
),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const facets = {
|
||||
q,
|
||||
...(tag ? { tag } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
};
|
||||
const rangeInvalid = Boolean(from && to && from > to);
|
||||
const search = useInfiniteQuery({
|
||||
queryKey: queryKeys.documentSourceSearch(slug ?? "", facets),
|
||||
enabled: open && slug !== null && !rangeInvalid,
|
||||
initialPageParam: 1,
|
||||
queryFn: ({ pageParam }) => {
|
||||
const sp = new URLSearchParams({ page: String(pageParam) });
|
||||
if (q) sp.set("q", q);
|
||||
if (tag) sp.set("tag", tag);
|
||||
if (from) sp.set("from", from);
|
||||
if (to) sp.set("to", to);
|
||||
return apiGet<DocumentSourceSearchDto>(
|
||||
`/api/documents/sources/${slug}/search?${sp.toString()}`,
|
||||
);
|
||||
},
|
||||
getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const results = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: DocumentSourceResultDto[] = [];
|
||||
for (const page of search.data?.pages ?? []) {
|
||||
for (const row of page.results) {
|
||||
if (seen.has(row.sourceId)) continue;
|
||||
seen.add(row.sourceId);
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [search.data]);
|
||||
|
||||
// An already imported document is worth picking only when the pick does
|
||||
// something with it: link it, or hand it to the form.
|
||||
const canTakeImported = Boolean(link || onImported);
|
||||
const selectable = (row: DocumentSourceResultDto) =>
|
||||
row.state === "new" || (row.state === "imported" && canTakeImported);
|
||||
|
||||
const toggle = (row: DocumentSourceResultDto) => {
|
||||
if (running || finished || !selectable(row)) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(row.sourceId)) next.delete(row.sourceId);
|
||||
else if (next.size < DOCUMENT_PICKER_MAX_SELECTION) {
|
||||
next.set(row.sourceId, row.title);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function runImport() {
|
||||
if (!slug || selected.size === 0) return;
|
||||
setRunning(true);
|
||||
setStopMessage(null);
|
||||
const order = [...selected.keys()];
|
||||
setRuns(new Map(order.map((id) => [id, { state: "waiting" }])));
|
||||
const handed: string[] = [];
|
||||
const mark = (id: string, run: RowRun) =>
|
||||
setRuns((prev) => new Map(prev).set(id, run));
|
||||
|
||||
for (const sourceId of order) {
|
||||
mark(sourceId, { state: "working" });
|
||||
try {
|
||||
const result = await apiPost<DocumentSourceImportDto>(
|
||||
`/api/documents/sources/${slug}/import`,
|
||||
{
|
||||
sourceId,
|
||||
...(kind ? { kind } : {}),
|
||||
...(link ? { link } : {}),
|
||||
},
|
||||
// The server may take up to a minute to fetch a large file from
|
||||
// the source; the default 15 s client timeout would abandon it.
|
||||
{ signal: AbortSignal.timeout(IMPORT_TIMEOUT_MS) },
|
||||
);
|
||||
mark(sourceId, { state: result.outcome, linked: result.linked });
|
||||
if (result.outcome !== "deleted" && result.documentId) {
|
||||
handed.push(result.documentId);
|
||||
}
|
||||
} catch (err) {
|
||||
const code = errorCodeOf(err);
|
||||
const message = sourceErrorMessage(t, code);
|
||||
mark(sourceId, { state: "failed", message });
|
||||
if (isStoppingError(code)) {
|
||||
setStopMessage(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRunning(false);
|
||||
setFinished(true);
|
||||
setSelected(new Map());
|
||||
// The vault, its pickers and the record's own lists read documents
|
||||
// through the `["documents"]` prefix; the picker's states through its own.
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.documents(),
|
||||
queryKeys.documentSources(),
|
||||
]);
|
||||
if (handed.length > 0) onImported?.(handed);
|
||||
}
|
||||
|
||||
const counts = useMemo(() => {
|
||||
let imported = 0;
|
||||
let present = 0;
|
||||
let failed = 0;
|
||||
for (const run of runs.values()) {
|
||||
if (run.state === "imported") imported += 1;
|
||||
else if (run.state === "duplicate" || run.state === "deleted")
|
||||
present += 1;
|
||||
else if (run.state === "failed") failed += 1;
|
||||
}
|
||||
return { imported, present, failed };
|
||||
}, [runs]);
|
||||
|
||||
const name = active ? systemName(active) : "";
|
||||
const hasFilters = Boolean(tag || from || to);
|
||||
|
||||
const footer = (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<p
|
||||
className="text-muted-foreground min-w-0 text-xs"
|
||||
data-slot="document-source-selection"
|
||||
>
|
||||
{finished
|
||||
? t("documents.sourcePicker.summary", counts)
|
||||
: running
|
||||
? t("documents.sourcePicker.importing")
|
||||
: t("documents.sourcePicker.selectedCount", {
|
||||
count: selected.size,
|
||||
max: DOCUMENT_PICKER_MAX_SELECTION,
|
||||
})}
|
||||
</p>
|
||||
{finished ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("documents.sourcePicker.done")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
disabled={running || selected.size === 0}
|
||||
onClick={() => void runImport()}
|
||||
data-slot="document-source-import"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("documents.sourcePicker.importAction", { count: selected.size })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
// A run in progress finishes before the sheet can close; its rows
|
||||
// are the only place the outcome is shown.
|
||||
if (!next && running) return;
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
}}
|
||||
title={t("documents.sourcePicker.title", { name })}
|
||||
description={t("documents.sourcePicker.description")}
|
||||
contentWidth="lg"
|
||||
footer={footer}
|
||||
>
|
||||
<div className="space-y-3" data-slot="document-source-picker">
|
||||
{systems.length > 1 ? (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("documents.sourcePicker.sourceLabel")}
|
||||
className="bg-muted inline-flex rounded-md p-1"
|
||||
>
|
||||
{systems.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={option === active}
|
||||
disabled={running}
|
||||
onClick={() => switchSystem(option)}
|
||||
className={cn(
|
||||
"min-h-9 rounded-sm px-3 text-sm font-medium",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
option === active
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{systemName(option)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-source-search">
|
||||
{t("documents.sourcePicker.searchLabel")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
id="document-source-search"
|
||||
type="search"
|
||||
autoFocus
|
||||
value={searchDraft}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("documents.sourcePicker.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
data-slot="document-source-search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-source-tag">
|
||||
{t("documents.sourcePicker.tagLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="document-source-tag"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
disabled={tags.isPending && !tags.isError}
|
||||
>
|
||||
<option value="">{t("documents.sourcePicker.tagAny")}</option>
|
||||
{(tags.data?.tags ?? []).map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-source-from">
|
||||
{t("documents.sourcePicker.fromLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="document-source-from"
|
||||
value={from}
|
||||
onChange={setFrom}
|
||||
max={to || undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-source-to">
|
||||
{t("documents.sourcePicker.toLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="document-source-to"
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
min={from || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasFilters ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTag("");
|
||||
setFrom("");
|
||||
setTo("");
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-8 items-center gap-1 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
{t("documents.sourcePicker.clearFilters")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{stopMessage ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-slot="document-source-stopped"
|
||||
>
|
||||
{t("documents.sourcePicker.stopped", { reason: stopMessage })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{rangeInvalid ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("documents.sourcePicker.rangeInvalid")}
|
||||
</p>
|
||||
) : search.isPending ? (
|
||||
<div className="space-y-2" data-slot="document-source-loading">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : search.isError ? (
|
||||
<QueryErrorRow
|
||||
message={sourceErrorMessage(t, errorCodeOf(search.error))}
|
||||
onRetry={() => void search.refetch()}
|
||||
slot="document-source-error"
|
||||
/>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
{q || hasFilters
|
||||
? t("documents.sourcePicker.noMatch")
|
||||
: t("documents.sourcePicker.empty", { name })}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
className="max-h-[45vh] space-y-1.5 overflow-y-auto overscroll-contain"
|
||||
data-slot="document-source-results"
|
||||
>
|
||||
{results.map((row) => {
|
||||
const on = selected.has(row.sourceId);
|
||||
const run = runs.get(row.sourceId);
|
||||
const allowed = selectable(row);
|
||||
const meta = [
|
||||
row.date ? format.date(`${row.date}T12:00:00.000Z`) : null,
|
||||
row.sizeBytes !== null
|
||||
? formatBytes(row.sizeBytes, locale)
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<li key={row.sourceId}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
aria-disabled={!allowed || running || finished}
|
||||
onClick={() => toggle(row)}
|
||||
data-slot="document-source-result"
|
||||
data-state={row.state}
|
||||
className={cn(
|
||||
"border-border flex min-h-11 w-full items-start gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
allowed && !running && !finished && "hover:bg-muted/50",
|
||||
on && "border-primary/40 bg-primary/5",
|
||||
!allowed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"text-primary mt-0.5 size-4 shrink-0",
|
||||
on ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{row.title}
|
||||
</span>
|
||||
{meta ? (
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{meta}
|
||||
</span>
|
||||
) : null}
|
||||
{row.tags.length > 0 ? (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{row.tags.slice(0, 6).map((name) => (
|
||||
<TagChip key={name}>{name}</TagChip>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
{run ? <RunLine run={run} /> : null}
|
||||
</span>
|
||||
{row.state !== "new" && !run ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{row.state === "imported"
|
||||
? t("documents.sourcePicker.stateImported")
|
||||
: t("documents.sourcePicker.stateDeleted")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{search.hasNextPage && !search.isError ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
disabled={search.isFetchingNextPage}
|
||||
onClick={() => void search.fetchNextPage()}
|
||||
>
|
||||
{search.isFetchingNextPage ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("documents.sourcePicker.loadMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row's progress or result, under its title. */
|
||||
function RunLine({ run }: { run: RowRun }) {
|
||||
const { t } = useTranslations();
|
||||
switch (run.state) {
|
||||
case "waiting":
|
||||
return (
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("documents.sourcePicker.result.waiting")}
|
||||
</span>
|
||||
);
|
||||
case "working":
|
||||
return (
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("documents.sourcePicker.result.working")}
|
||||
</span>
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
<span
|
||||
className="text-destructive flex items-start gap-1.5 text-xs"
|
||||
data-slot="document-source-run-failed"
|
||||
>
|
||||
<CircleAlert className="mt-px size-3.5 shrink-0" aria-hidden />
|
||||
{run.message}
|
||||
</span>
|
||||
);
|
||||
case "deleted":
|
||||
return (
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("documents.sourcePicker.result.deleted")}
|
||||
</span>
|
||||
);
|
||||
case "imported":
|
||||
case "duplicate":
|
||||
return (
|
||||
<span
|
||||
className="text-success flex items-center gap-1.5 text-xs"
|
||||
data-slot="document-source-run-done"
|
||||
>
|
||||
<CircleCheck className="size-3.5 shrink-0" aria-hidden />
|
||||
{run.state === "imported"
|
||||
? run.linked
|
||||
? t("documents.sourcePicker.result.importedLinked")
|
||||
: t("documents.sourcePicker.result.imported")
|
||||
: run.linked
|
||||
? t("documents.sourcePicker.result.duplicateLinked")
|
||||
: t("documents.sourcePicker.result.duplicate")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Import from Paperless-ngx / Papra": the button that opens the document
|
||||
* picker (#1038), for every place a document can be added or linked.
|
||||
*
|
||||
* Renders nothing unless the person, in their own record, has at least one
|
||||
* usable connection: no connection, a delegate or guardian, the module off, or
|
||||
* the operator's list unset all leave no trace, because a button that can only
|
||||
* lead to a refusal advertises a feature that is not there. Connecting happens
|
||||
* in Settings, not here.
|
||||
*/
|
||||
import { Import } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
import {
|
||||
DocumentSourcePicker,
|
||||
type DocumentPickerLink,
|
||||
} from "./document-source-picker";
|
||||
import { systemName, useDocumentSourcesStatus } from "./use-document-sources";
|
||||
|
||||
export function ImportFromSourceButton({
|
||||
link,
|
||||
kind,
|
||||
onImported,
|
||||
className,
|
||||
}: {
|
||||
link?: DocumentPickerLink;
|
||||
kind?: InboundDocumentKindValue;
|
||||
onImported?: (documentIds: string[]) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { connected } = useDocumentSourcesStatus();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (connected.length === 0) return null;
|
||||
|
||||
const label =
|
||||
connected.length === 1
|
||||
? t("documents.sourcePicker.openNamed", {
|
||||
name: systemName(connected[0]),
|
||||
})
|
||||
: t("documents.sourcePicker.open");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={className}
|
||||
onClick={() => setOpen(true)}
|
||||
data-slot="document-source-open"
|
||||
>
|
||||
<Import className="size-4" aria-hidden />
|
||||
{label}
|
||||
</Button>
|
||||
<DocumentSourcePicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
systems={connected}
|
||||
link={link}
|
||||
kind={kind}
|
||||
onImported={onImported}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Add imported ids to a form's selection, in order, never twice. */
|
||||
export function mergeIds(selected: string[], added: string[]): string[] {
|
||||
const merged = [...selected];
|
||||
for (const id of added) if (!merged.includes(id)) merged.push(id);
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The document picker's error codes (#1038), as sentences. One place for the
|
||||
* sheet and the settings card, so the same refusal reads the same on both.
|
||||
* Every branch names its key literally so the i18n coverage guard can see it.
|
||||
*/
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** The `meta.errorCode` a failed request carried, if any. */
|
||||
export function errorCodeOf(err: unknown): string | undefined {
|
||||
if (err instanceof ApiError) {
|
||||
const code = err.meta?.errorCode;
|
||||
if (typeof code === "string") return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Is this a refusal that will not change on the next document? */
|
||||
export function isStoppingError(code: string | undefined): boolean {
|
||||
return (
|
||||
code === "documents.inbound.rateLimited" ||
|
||||
code === "documents.inbound.quotaExceeded" ||
|
||||
code === "documents.sources.authRefused" ||
|
||||
code === "documents.sources.unreachable" ||
|
||||
code === "documents.sources.originNotAllowed" ||
|
||||
code === "documents.sources.notConnected" ||
|
||||
code === "documents.sources.unavailable" ||
|
||||
code === "documents.sources.browserOnly" ||
|
||||
code === "documents.sources.linkTargetNotFound"
|
||||
);
|
||||
}
|
||||
|
||||
export function sourceErrorMessage(t: T, code: string | undefined): string {
|
||||
switch (code) {
|
||||
case "documents.sources.unavailable":
|
||||
return t("documents.sourcePicker.errors.unavailable");
|
||||
case "documents.sources.notConnected":
|
||||
return t("documents.sourcePicker.errors.notConnected");
|
||||
case "documents.sources.originNotAllowed":
|
||||
return t("documents.sourcePicker.errors.originNotAllowed");
|
||||
case "documents.sources.unreachable":
|
||||
return t("documents.sourcePicker.errors.unreachable");
|
||||
case "documents.sources.redirected":
|
||||
return t("documents.sourcePicker.errors.redirected");
|
||||
case "documents.sources.authRefused":
|
||||
return t("documents.sourcePicker.errors.authRefused");
|
||||
case "documents.sources.versionTooOld":
|
||||
return t("documents.sourcePicker.errors.versionTooOld");
|
||||
case "documents.sources.badResponse":
|
||||
return t("documents.sourcePicker.errors.badResponse");
|
||||
case "documents.sources.notFound":
|
||||
return t("documents.sourcePicker.errors.notFound");
|
||||
case "documents.sources.linkTargetNotFound":
|
||||
return t("documents.sourcePicker.errors.linkTargetNotFound");
|
||||
case "documents.sources.rateLimited":
|
||||
return t("documents.sourcePicker.errors.rateLimited");
|
||||
case "documents.sources.browserOnly":
|
||||
return t("documents.sourcePicker.errors.browserOnly");
|
||||
case "documents.sources.invalidAddress":
|
||||
return t("documents.sourcePicker.errors.invalidAddress");
|
||||
case "documents.sources.organizationRequired":
|
||||
return t("documents.sourcePicker.errors.organizationRequired");
|
||||
case "documents.sources.tokenRequired":
|
||||
return t("documents.sourcePicker.errors.tokenRequired");
|
||||
case "documents.inbound.fileTooLarge":
|
||||
return t("documents.sourcePicker.errors.fileTooLarge");
|
||||
case "documents.inbound.quotaExceeded":
|
||||
return t("documents.sourcePicker.errors.quotaExceeded");
|
||||
case "documents.inbound.fileType":
|
||||
return t("documents.sourcePicker.errors.fileType");
|
||||
case "documents.inbound.rateLimited":
|
||||
return t("documents.sourcePicker.errors.uploadLimit");
|
||||
case "documents.inbound.uploadBusy":
|
||||
return t("documents.sourcePicker.errors.uploadBusy");
|
||||
case "module.disabled":
|
||||
return t("documents.sourcePicker.errors.moduleDisabled");
|
||||
default:
|
||||
return t("documents.sourcePicker.errors.generic");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The document picker's availability for the person on this screen (#1038).
|
||||
*
|
||||
* Reads `GET /api/documents/sources` only when it can matter: the documents
|
||||
* module is on and the person is in their own record with the right to manage
|
||||
* documents. A delegate or guardian never asks (the route would refuse them
|
||||
* anyway), so the import buttons and the settings card render nothing for
|
||||
* them. `connected` is the list of systems with a usable connection, in a
|
||||
* stable order; an entry whose origin the operator removed is left out.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
DocumentPickerSystem,
|
||||
DocumentSourcesStatusDto,
|
||||
} from "@/lib/documents/sources/types";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function useDocumentSourcesStatus() {
|
||||
const { user } = useAuth();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const eligible =
|
||||
user?.modules?.inboundDocuments === true &&
|
||||
!inSharedRecord &&
|
||||
canManageDomain("documents");
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.documentSourcesStatus(),
|
||||
enabled: eligible,
|
||||
queryFn: () => apiGet<DocumentSourcesStatusDto>("/api/documents/sources"),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const connected: DocumentPickerSystem[] = eligible
|
||||
? (query.data?.connections ?? [])
|
||||
.filter((c) => c.originAllowed)
|
||||
.map((c) => c.system)
|
||||
: [];
|
||||
|
||||
return { eligible, query, connected };
|
||||
}
|
||||
|
||||
/** Display name of a system; product names, not translated. */
|
||||
export function systemName(system: DocumentPickerSystem): string {
|
||||
return system === "PAPERLESS" ? "Paperless-ngx" : "Papra";
|
||||
}
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
type EntityLinkOption,
|
||||
} from "@/components/links/entity-link-picker";
|
||||
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
|
||||
import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
|
||||
import { mergeIds } from "@/components/documents/sources/merge-ids";
|
||||
|
||||
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
|
||||
export const PICKER_FETCH_LIMIT = 200;
|
||||
@@ -152,6 +154,17 @@ export function EncounterLinkPickers({
|
||||
options={documents.options}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("encounters.form.linkNothingToOffer")}
|
||||
extraAction={
|
||||
// The form owns its selection and replaces the visit's links on
|
||||
// save, so imported documents join the selection rather than
|
||||
// being linked on the server.
|
||||
<ImportFromSourceButton
|
||||
className="min-h-9"
|
||||
onImported={(ids) =>
|
||||
onChange({ documentIds: mergeIds(documentIds, ids) })
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* carries the option's date, so two doses of one vaccine read as two
|
||||
* different records.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, Plus, Search, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -164,6 +164,7 @@ export function EntityLinkPicker({
|
||||
error = false,
|
||||
errorLabel,
|
||||
onRetry,
|
||||
extraAction,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
@@ -183,6 +184,13 @@ export function EntityLinkPicker({
|
||||
error?: boolean;
|
||||
errorLabel?: string;
|
||||
onRetry?: () => void;
|
||||
/**
|
||||
* A second way to add, beside the add button: the documents pickers put
|
||||
* "Import from Paperless-ngx / Papra" here. Shown even when there is
|
||||
* nothing to link yet, because an empty vault is exactly when importing is
|
||||
* the way forward. The element renders nothing when it has nothing to offer.
|
||||
*/
|
||||
extraAction?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -238,7 +246,10 @@ export function EntityLinkPicker({
|
||||
slot={`${slot}-error`}
|
||||
/>
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
|
||||
{extraAction}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedChips.length > 0 ? (
|
||||
@@ -312,17 +323,20 @@ export function EntityLinkPicker({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
data-slot={`${slot}-add`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("links.picker.add")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
data-slot={`${slot}-add`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("links.picker.add")}
|
||||
</Button>
|
||||
{extraAction}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { ConnectionsPanel } from "@/components/settings/integrations/connections
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
|
||||
import { DocumentSourcesCard } from "@/components/settings/integrations/document-sources-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export {
|
||||
@@ -77,6 +78,11 @@ export function IntegrationsSection({
|
||||
under the same Connections group rather than a new labelled
|
||||
section that could paint an orphaned heading over nothing. */}
|
||||
<NutrientIntakeCard />
|
||||
|
||||
{/* v1.39.3 (#1038) — Paperless-ngx / Papra for the document picker.
|
||||
Renders nothing unless the operator listed an origin in
|
||||
DOCUMENT_SOURCE_ORIGINS and the person is in their own record. */}
|
||||
<DocumentSourcesCard />
|
||||
</section>
|
||||
|
||||
{/* Delivery channels ("where it's delivered"). The existing channels
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings → Integrations → Document archives (#1038): connect Paperless-ngx or
|
||||
* Papra so the document picker can search it.
|
||||
*
|
||||
* Renders nothing unless the operator listed at least one origin in
|
||||
* `DOCUMENT_SOURCE_ORIGINS`, the documents module is on, and the person is in
|
||||
* their own record: a delegate never manages the owner's connection, and a
|
||||
* card that can only be refused advertises a feature that is not there.
|
||||
*
|
||||
* One form per system behind a switch. Save tests the connection before it is
|
||||
* stored and says why when it fails; the token field never shows the stored
|
||||
* token (the server never returns it), only that one is saved, and leaving it
|
||||
* empty keeps it. Results sit above the action row; nothing follows it
|
||||
* (UI standards §12).
|
||||
*/
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Archive, CheckCircle2, Loader2, Unlink } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import {
|
||||
errorCodeOf,
|
||||
sourceErrorMessage,
|
||||
} from "@/components/documents/sources/source-errors";
|
||||
import {
|
||||
systemName,
|
||||
useDocumentSourcesStatus,
|
||||
} from "@/components/documents/sources/use-document-sources";
|
||||
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
DOCUMENT_PICKER_SYSTEMS,
|
||||
slugForSystem,
|
||||
type DocumentPickerSystem,
|
||||
type DocumentSourceConnectionDto,
|
||||
} from "@/lib/documents/sources/types";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Outcome = { tone: "success" | "error"; message: string } | null;
|
||||
|
||||
export function DocumentSourcesCard() {
|
||||
const { t } = useTranslations();
|
||||
const { eligible, query } = useDocumentSourcesStatus();
|
||||
const [system, setSystem] = useState<DocumentPickerSystem>("PAPERLESS");
|
||||
|
||||
if (!eligible || !query.data?.available) return null;
|
||||
|
||||
const connections = query.data.connections;
|
||||
const connectedCount = connections.length;
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="document-sources-card">
|
||||
<SettingsCardHeader
|
||||
icon={Archive}
|
||||
title={t("settings.documentSources.title")}
|
||||
description={t("settings.documentSources.description")}
|
||||
status={
|
||||
connectedCount > 0 ? (
|
||||
<Badge variant="outline">
|
||||
{t("settings.documentSources.statusConnected")}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-sm">{t("settings.documentSources.explainer")}</p>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("settings.documentSources.systemLabel")}
|
||||
className="bg-muted inline-flex self-start rounded-md p-1"
|
||||
>
|
||||
{DOCUMENT_PICKER_SYSTEMS.map((option) => {
|
||||
const isConnected = connections.some((c) => c.system === option);
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={option === system}
|
||||
onClick={() => setSystem(option)}
|
||||
data-slot="document-sources-system"
|
||||
className={cn(
|
||||
"flex min-h-9 items-center gap-1.5 rounded-sm px-3 text-sm font-medium",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
option === system
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{systemName(option)}
|
||||
{isConnected ? (
|
||||
<CheckCircle2
|
||||
className="text-success size-3.5"
|
||||
aria-label={t("settings.documentSources.statusConnected")}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SourceForm
|
||||
// A fresh form per system: nothing typed for one leaks into the other.
|
||||
key={system}
|
||||
system={system}
|
||||
connection={connections.find((c) => c.system === system) ?? null}
|
||||
allowedOrigins={query.data.allowedOrigins}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceForm({
|
||||
system,
|
||||
connection,
|
||||
allowedOrigins,
|
||||
}: {
|
||||
system: DocumentPickerSystem;
|
||||
connection: DocumentSourceConnectionDto | null;
|
||||
allowedOrigins: string[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const slug = slugForSystem(system);
|
||||
const isPapra = system === "PAPRA";
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState(connection?.baseUrl ?? "");
|
||||
const [organizationId, setOrganizationId] = useState(
|
||||
connection?.organizationId ?? "",
|
||||
);
|
||||
const [token, setToken] = useState("");
|
||||
const [outcome, setOutcome] = useState<Outcome>(null);
|
||||
|
||||
const dirty =
|
||||
baseUrl.trim() !== (connection?.baseUrl ?? "") ||
|
||||
(isPapra && organizationId.trim() !== (connection?.organizationId ?? "")) ||
|
||||
token.trim() !== "";
|
||||
|
||||
const refresh = () =>
|
||||
invalidateKeys(queryClient, [queryKeys.documentSources()]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPut<DocumentSourceConnectionDto>(`/api/documents/sources/${slug}`, {
|
||||
baseUrl: baseUrl.trim(),
|
||||
...(isPapra ? { organizationId: organizationId.trim() } : {}),
|
||||
...(token.trim() ? { token: token.trim() } : {}),
|
||||
}),
|
||||
onMutate: () => setOutcome(null),
|
||||
onSuccess: () => {
|
||||
setToken("");
|
||||
setOutcome({
|
||||
tone: "success",
|
||||
message: t("settings.documentSources.saved"),
|
||||
});
|
||||
void refresh();
|
||||
},
|
||||
onError: (err) =>
|
||||
setOutcome({
|
||||
tone: "error",
|
||||
message: sourceErrorMessage(t, errorCodeOf(err)),
|
||||
}),
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPost<{ ok: true; latencyMs: number }>(
|
||||
`/api/documents/sources/${slug}/test`,
|
||||
),
|
||||
onMutate: () => setOutcome(null),
|
||||
onSuccess: (data) => {
|
||||
setOutcome({
|
||||
tone: "success",
|
||||
message: t("settings.documentSources.testOk", {
|
||||
latency: data.latencyMs,
|
||||
}),
|
||||
});
|
||||
void refresh();
|
||||
},
|
||||
onError: (err) =>
|
||||
setOutcome({
|
||||
tone: "error",
|
||||
message: sourceErrorMessage(t, errorCodeOf(err)),
|
||||
}),
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => apiDelete(`/api/documents/sources/${slug}`),
|
||||
onSuccess: () => {
|
||||
setBaseUrl("");
|
||||
setOrganizationId("");
|
||||
setToken("");
|
||||
setOutcome(null);
|
||||
void refresh();
|
||||
},
|
||||
onError: (err) =>
|
||||
setOutcome({
|
||||
tone: "error",
|
||||
message: sourceErrorMessage(t, errorCodeOf(err)),
|
||||
}),
|
||||
});
|
||||
|
||||
const busy = save.isPending || test.isPending || disconnect.isPending;
|
||||
const canSave =
|
||||
baseUrl.trim() !== "" &&
|
||||
(!isPapra || organizationId.trim() !== "") &&
|
||||
(connection !== null || token.trim() !== "") &&
|
||||
dirty;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canSave && !busy) save.mutate();
|
||||
}}
|
||||
data-slot="document-sources-form"
|
||||
>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="document-sources-state"
|
||||
>
|
||||
{connection
|
||||
? connection.lastVerifiedAt
|
||||
? t("settings.documentSources.connectedSince", {
|
||||
date: format.date(connection.lastVerifiedAt),
|
||||
})
|
||||
: t("settings.documentSources.statusConnected")
|
||||
: t("settings.documentSources.statusNotConnected")}
|
||||
</p>
|
||||
|
||||
{connection && !connection.originAllowed ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="border-warning/30 bg-warning/10 text-foreground rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{t("settings.documentSources.originRemoved")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`document-source-url-${slug}`}>
|
||||
{t("settings.documentSources.baseUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`document-source-url-${slug}`}
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder={allowedOrigins[0] ?? "https://"}
|
||||
maxLength={2048}
|
||||
autoComplete="off"
|
||||
inputMode="url"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.documentSources.allowedOrigins", {
|
||||
origins: allowedOrigins.join(", "),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isPapra ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-source-org">
|
||||
{t("settings.documentSources.organizationId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="document-source-org"
|
||||
value={organizationId}
|
||||
onChange={(e) => setOrganizationId(e.target.value)}
|
||||
maxLength={128}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.documentSources.organizationHelp")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`document-source-token-${slug}`}>
|
||||
{t("settings.documentSources.token")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id={`document-source-token-${slug}`}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={
|
||||
connection ? t("settings.documentSources.tokenSaved") : undefined
|
||||
}
|
||||
maxLength={512}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{isPapra
|
||||
? t("settings.documentSources.tokenHelpPapra")
|
||||
: t("settings.documentSources.tokenHelpPaperless")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{outcome ? (
|
||||
<p
|
||||
role={outcome.tone === "error" ? "alert" : "status"}
|
||||
data-slot="document-sources-outcome"
|
||||
className={cn(
|
||||
"text-sm",
|
||||
outcome.tone === "error" ? "text-destructive" : "text-success",
|
||||
)}
|
||||
>
|
||||
{outcome.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsCardActions>
|
||||
{connection ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={busy}
|
||||
>
|
||||
<Unlink className="size-4" aria-hidden />
|
||||
{t("settings.documentSources.disconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.documentSources.disconnectTitle", {
|
||||
name: systemName(system),
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.documentSources.disconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => disconnect.mutate()}>
|
||||
{t("settings.documentSources.disconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
{connection ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={busy || dirty || !connection.originAllowed}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{test.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.documentSources.test")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={busy || !canSave}
|
||||
>
|
||||
{save.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{save.isPending
|
||||
? t("settings.documentSources.saving")
|
||||
: t("settings.documentSources.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import { FolderOpen } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
|
||||
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
|
||||
import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
|
||||
import { mergeIds } from "@/components/documents/sources/merge-ids";
|
||||
|
||||
export function VaccinationDocumentPicker({
|
||||
enabled,
|
||||
@@ -73,6 +75,14 @@ export function VaccinationDocumentPicker({
|
||||
options={vault.options}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("vaccinations.form.linkNothingToOffer")}
|
||||
extraAction={
|
||||
// Joins the form's selection; the dose's save writes the links.
|
||||
<ImportFromSourceButton
|
||||
className="min-h-9"
|
||||
kind="VACCINATION"
|
||||
onImported={(ids) => onChange(mergeIds(documentIds, ids))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -81,4 +81,27 @@ export const documentKeys = {
|
||||
* toggle; a flip invalidates the document AI capability probe.
|
||||
*/
|
||||
documentsAutoAiRead: () => ["documents", "auto-ai-read"] as const,
|
||||
/**
|
||||
* The document picker (#1038): its own top-level prefix, deliberately NOT
|
||||
* under `["documents"]`. Every vault write invalidates that prefix, and a
|
||||
* search or tag read here is a request to the person's Paperless-ngx or
|
||||
* Papra instance; a filing action in the vault must not re-send them.
|
||||
*/
|
||||
documentSources: () => ["documentSources"] as const,
|
||||
/** Availability + the person's connections (`GET /api/documents/sources`). */
|
||||
documentSourcesStatus: () => ["documentSources", "status"] as const,
|
||||
/** One source's tags for the filter. */
|
||||
documentSourceTags: (system: string) =>
|
||||
["documentSources", "tags", system] as const,
|
||||
/** One page of one source's search. */
|
||||
documentSourceSearch: (system: string, search: DocumentSourceSearchKey) =>
|
||||
["documentSources", "search", system, search] as const,
|
||||
};
|
||||
|
||||
/** The facets that make one picker search distinct from another. */
|
||||
export interface DocumentSourceSearchKey {
|
||||
q: string;
|
||||
tag?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
Reference in new issue
Block a user