feat(documents): the vault surface

The page becomes one wide, calm filing-cabinet view: PageHeader with an
Upload action, a drop-target upload zone whose accept list and per-file
cap come from the usage endpoint (quota bar only above 80 %), a sticky
filter rail (200 ms debounced search focused by '/', nine type chips,
condition chips from linked episodes, a year segmenter, one-tap clear),
and the virtualized timeline below. Filter state lives in the URL so
every view is shareable and back-button-safe; uploads filtered to an
episode arrive pre-linked. Hovering a card prefetches detail metadata —
never the blob. Designed states: a skeleton grid that matches the loaded
layout, a teaching empty state, a distinct no-matches state, and a
retryable error card. /documents joins the shell's wide-container list;
the shell stays the single owner of the page frame. Locale bundles gain
the vault vocabulary in all six languages and drop the retired
extraction-review strings.

Plan-Task: W2-T1, W2-T4 (zone), W2-T5 (prefetch), W2-T6
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 12:26:06 +02:00
1 parent 4014d77a21
commit b8009d2e73
12 files changed
+1268 -754

No files matched your search

+64 -119
View File
@@ -8015,19 +8015,8 @@
},
"documents": {
"title": "Dokumente",
"subtitle": "Speichere deine medizinischen Dokumente und prüfe, was darin steht.",
"upload": {
"label": "Dokument hinzufügen",
"kindLabel": "Kategorie",
"button": "Hochladen",
"uploading": "Wird hochgeladen…",
"fileLabel": "Datei (erforderlich)",
"titleLabel": "Titel (optional)",
"titlePlaceholder": "z. B. Kardiologie-Bericht",
"dateLabel": "Dokumentdatum (optional)",
"fileRequired": "Wähle eine Datei zum Hochladen.",
"accepts": "JPEG, PNG, WebP oder PDF · bis 12 MB"
},
"subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.",
"pageUpload": "Hochladen",
"kind": {
"DOCTOR_REPORT": "Arztbericht",
"DISCHARGE_LETTER": "Entlassungsbrief",
@@ -8039,121 +8028,77 @@
"INSURANCE": "Versicherung",
"VACCINATION": "Impfung"
},
"list": {
"empty": "Noch keine Dokumente.",
"noResults": "Keine Dokumente passen zu deinen Filtern.",
"loadMore": "Mehr laden",
"loadingMore": "Wird geladen…",
"pending": "{count} zu prüfen",
"facts": "{count} Angaben",
"clearFilters": "Filter zurücksetzen",
"loadError": "Deine Dokumente konnten nicht geladen werden."
"filter": {
"searchLabel": "Dokumente durchsuchen",
"searchPlaceholder": "Titel oder Dateiname suchen…",
"groupLabel": "Dokumente filtern",
"typeGroup": "Dokumenttyp",
"conditionGroup": "Erkrankung",
"yearGroup": "Jahr",
"clear": "Filter zurücksetzen"
},
"status": {
"EXTRACTING": "Wird gelesen",
"EXTRACTED": "Bereit zur Prüfung",
"FAILED": "Konnte nicht gelesen werden",
"CONFIRMED": "Geprüft",
"DISCARDED": "Verworfen",
"STORED": "Gespeichert"
"card": {
"untitled": "Unbenanntes Dokument",
"selectLabel": "{title} auswählen",
"openLabel": "{title} öffnen",
"attachmentBadge": "Nur Download",
"uploading": "Wird hochgeladen…",
"dismissFailed": "Fehlgeschlagenen Upload ausblenden"
},
"review": {
"title": "Erkannte Angaben prüfen",
"confirm": "Bestätigte Angaben speichern",
"confirming": "Wird gespeichert…",
"needsReview": "Prüfung nötig",
"approve": "Übernehmen",
"reject": "Ablehnen",
"edit": "Bearbeiten",
"cancel": "Abbrechen",
"save": "Speichern",
"needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.",
"confidenceLabel": "Erkennungssicherheit",
"commitError": {
"unitMismatch": "die Einheit stimmt nicht mit der gespeicherten überein",
"unitRequired": "ein Zahlenwert braucht eine Einheit",
"generic": "es konnte nicht gespeichert werden"
}
},
"empty": {
"noFacts": "Nichts mehr zu prüfen."
},
"factType": {
"CONDITION": "Diagnose",
"OBSERVATION": "Wert",
"MEDICATION_STATEMENT": "Medikament"
},
"fields": {
"label": "Name",
"value": "Wert",
"unit": "Einheit",
"date": "Datum",
"dose": "Dosis"
"error": {
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
"quotaExceeded": "Der Speicher ist voll — {used} von {quota} belegt.",
"unsupportedType": "Dieser Dateityp wird nicht unterstützt.",
"purged": "Dieses Dokument wurde endgültig entfernt und kann nicht wiederhergestellt werden.",
"duplicateExists": "Ein identisches Dokument ist bereits gespeichert.",
"rateLimited": "Zu viele Uploads — versuche es in ein paar Minuten erneut.",
"generic": "Upload fehlgeschlagen. Bitte erneut versuchen."
},
"toast": {
"uploaded": "Dokument gespeichert.",
"uploadFailed": "Dokument konnte nicht gespeichert werden.",
"confirmed": "Bestätigte Angaben gespeichert.",
"confirmedPartial": "Gespeichert, aber {count} konnten nicht gespeichert werden — {reason}.",
"confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.",
"saved": "Angabe aktualisiert.",
"discarded": "Dokument verworfen.",
"extracted": "Angaben ausgelesen. Prüfe sie unten.",
"extractFailed": "Angaben konnten nicht ausgelesen werden.",
"localOcrDisabled": "Aktiviere lokale OCR in den Einstellungen, um dieses Dokument auf dem Gerät zu lesen.",
"updated": "Dokument aktualisiert.",
"updateFailed": "Dokument konnte nicht aktualisiert werden.",
"discardFailed": "Dokument konnte nicht verworfen werden.",
"uploadTooLarge": "Datei ist zu groß (max. 12 MB).",
"uploadFileType": "Lade eine JPEG-, PNG-, WebP- oder PDF-Datei hoch.",
"uploadRateLimited": "Zu viele Uploads. Versuch es später erneut.",
"uploadInvalidMetadata": "Prüfe Titel, Kategorie oder Datum und versuch es erneut."
"duplicate": "Bereits gespeichert — das vorhandene Dokument wird hervorgehoben.",
"deleted": "Dokument gelöscht.",
"restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden."
},
"toolbar": {
"searchLabel": "Suche",
"searchPlaceholder": "Titel oder Dateiname",
"kindFilterLabel": "Kategorie",
"allKinds": "Alle Kategorien",
"sortLabel": "Sortieren nach",
"sort": {
"documentDate": "Dokumentdatum",
"createdAt": "Hinzugefügt am",
"title": "Titel"
},
"orderAsc": "Aufsteigend",
"orderDesc": "Absteigend",
"fromLabel": "Von",
"toLabel": "Bis"
"timeline": {
"loadingMore": "Mehr wird geladen…"
},
"upload": {
"zoneTitle": "Dateien hier ablegen oder vom Gerät auswählen",
"zoneHint": "PDF, Fotos und Office-Dateien bis je {maxSize}",
"browse": "Dateien auswählen",
"quotaAlmostFull": "Speicher fast voll",
"quotaUsed": "{used} von {quota} belegt"
},
"selection": {
"count": "{count} ausgewählt",
"clear": "Auswahl aufheben"
},
"list": {
"loadError": "Deine Dokumente konnten nicht geladen werden."
},
"empty": {
"title": "Dein Dokumenten-Tresor ist leer",
"description": "Bewahre Arztbriefe, Laborbefunde, Bildgebung, Rezepte und Impfnachweise auf — verschlüsselt und durchsuchbar nach Typ, Erkrankung und Jahr.",
"action": "Erstes Dokument hochladen",
"noMatchesTitle": "Keine passenden Dokumente",
"noMatchesDescription": "Nichts entspricht der aktuellen Suche und den Filtern."
},
"detail": {
"viewOriginal": "Original ansehen",
"edit": "Bearbeiten",
"extract": "Angaben auslesen",
"extracting": "Wird ausgelesen…",
"discard": "Verwerfen",
"notExtracted": "Noch keine Angaben ausgelesen. Nutze „Angaben auslesen“, um dieses Dokument zu lesen.",
"viewOriginalNewTab": "(öffnet in einem neuen Tab)",
"discardConfirmTitle": "Dieses Dokument verwerfen?",
"discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Angaben dauerhaft. Das lässt sich nicht rückgängig machen.",
"discardConfirmAction": "Verwerfen",
"discardCancel": "Abbrechen"
},
"edit": {
"title": "Dokument bearbeiten",
"subtitle": "Umbenennen, neu kategorisieren oder das Dokumentdatum setzen.",
"title": "Dokumentdetails",
"loadError": "Dieses Dokument konnte nicht geladen werden.",
"saveError": "Die Änderung konnte nicht gespeichert werden.",
"deleteError": "Das Dokument konnte nicht gelöscht werden.",
"download": "Herunterladen",
"delete": "Löschen",
"titleLabel": "Titel",
"kindLabel": "Kategorie",
"titlePlaceholder": "Titel hinzufügen",
"kindLabel": "Typ",
"dateLabel": "Dokumentdatum",
"save": "Speichern",
"saving": "Wird gespeichert…",
"cancel": "Abbrechen"
},
"note": {
"providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Angaben automatisch auszulesen.",
"alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden."
},
"committed": {
"title": "Aus diesem Dokument übernommen"
"conditionsLabel": "Verknüpfte Erkrankungen",
"linkCondition": "Verknüpfen",
"noConditions": "Mit keiner Erkrankung verknüpft",
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
"uploadedMeta": "Hinzugefügt am {date} · {size}"
}
},
"selfDescription": {
+64 -119
View File
@@ -8015,19 +8015,8 @@
},
"documents": {
"title": "Documents",
"subtitle": "Store your clinical documents and review what they say.",
"upload": {
"label": "Add a document",
"kindLabel": "Category",
"button": "Upload",
"uploading": "Uploading…",
"fileLabel": "File (required)",
"titleLabel": "Title (optional)",
"titlePlaceholder": "e.g. Cardiology report",
"dateLabel": "Document date (optional)",
"fileRequired": "Choose a file to upload.",
"accepts": "JPEG, PNG, WebP or PDF · up to 12 MB"
},
"subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.",
"pageUpload": "Upload",
"kind": {
"DOCTOR_REPORT": "Doctor report",
"DISCHARGE_LETTER": "Discharge letter",
@@ -8039,121 +8028,77 @@
"INSURANCE": "Insurance",
"VACCINATION": "Vaccination"
},
"list": {
"empty": "No documents yet.",
"noResults": "No documents match your filters.",
"loadMore": "Load more",
"loadingMore": "Loading…",
"pending": "{count} to review",
"facts": "{count} facts",
"clearFilters": "Clear filters",
"loadError": "Couldn't load your documents."
"filter": {
"searchLabel": "Search documents",
"searchPlaceholder": "Search title or filename…",
"groupLabel": "Filter documents",
"typeGroup": "Document type",
"conditionGroup": "Condition",
"yearGroup": "Year",
"clear": "Clear filters"
},
"status": {
"EXTRACTING": "Reading",
"EXTRACTED": "Ready to review",
"FAILED": "Couldn't read",
"CONFIRMED": "Reviewed",
"DISCARDED": "Discarded",
"STORED": "Stored"
"card": {
"untitled": "Untitled document",
"selectLabel": "Select {title}",
"openLabel": "Open {title}",
"attachmentBadge": "Download only",
"uploading": "Uploading…",
"dismissFailed": "Dismiss failed upload"
},
"review": {
"title": "Review extracted facts",
"confirm": "Save approved facts",
"confirming": "Saving…",
"needsReview": "Needs review",
"approve": "Approve",
"reject": "Reject",
"edit": "Edit",
"cancel": "Cancel",
"save": "Save",
"needsReviewHint": "Edit this fact to approve it.",
"confidenceLabel": "Extraction confidence",
"commitError": {
"unitMismatch": "the unit doesn't match the saved one",
"unitRequired": "a numeric value needs a unit",
"generic": "it couldn't be saved"
}
},
"empty": {
"noFacts": "Nothing left to review."
},
"factType": {
"CONDITION": "Diagnosis",
"OBSERVATION": "Value",
"MEDICATION_STATEMENT": "Medication"
},
"fields": {
"label": "Name",
"value": "Value",
"unit": "Unit",
"date": "Date",
"dose": "Dose"
"error": {
"fileTooLarge": "This file is larger than the {maxSize} limit.",
"quotaExceeded": "Storage is full — {used} of {quota} used.",
"unsupportedType": "This file type is not supported.",
"purged": "This document was permanently removed and cannot be restored.",
"duplicateExists": "An identical document is already stored.",
"rateLimited": "Too many uploads right now — try again in a few minutes.",
"generic": "Upload failed. Please try again."
},
"toast": {
"uploaded": "Document stored.",
"uploadFailed": "Couldn't store the document.",
"confirmed": "Approved facts saved.",
"confirmedPartial": "Saved, but {count} couldn't be saved — {reason}.",
"confirmFailed": "Couldn't save your decisions.",
"saved": "Fact updated.",
"discarded": "Document discarded.",
"extracted": "Facts extracted. Review them below.",
"extractFailed": "Couldn't extract facts.",
"localOcrDisabled": "Enable local OCR in settings to read this document on-device.",
"updated": "Document updated.",
"updateFailed": "Couldn't update the document.",
"discardFailed": "Couldn't discard the document.",
"uploadTooLarge": "File is too large (max 12 MB).",
"uploadFileType": "Upload a JPEG, PNG, WebP, or PDF.",
"uploadRateLimited": "Too many uploads. Try again later.",
"uploadInvalidMetadata": "Check the title, category, or date and try again."
"duplicate": "Already stored — highlighting the existing document.",
"deleted": "Document deleted.",
"restoreFailed": "Couldn't restore the document."
},
"toolbar": {
"searchLabel": "Search",
"searchPlaceholder": "Title or filename",
"kindFilterLabel": "Category",
"allKinds": "All categories",
"sortLabel": "Sort by",
"sort": {
"documentDate": "Document date",
"createdAt": "Date added",
"title": "Title"
},
"orderAsc": "Ascending",
"orderDesc": "Descending",
"fromLabel": "From",
"toLabel": "To"
"timeline": {
"loadingMore": "Loading more…"
},
"upload": {
"zoneTitle": "Drop files here, or choose from your device",
"zoneHint": "PDF, photos, and office files up to {maxSize} each",
"browse": "Choose files",
"quotaAlmostFull": "Storage almost full",
"quotaUsed": "{used} of {quota} used"
},
"selection": {
"count": "{count} selected",
"clear": "Clear selection"
},
"list": {
"loadError": "Couldn't load your documents."
},
"empty": {
"title": "Your document vault is empty",
"description": "Store doctor letters, lab reports, imaging, prescriptions, and vaccination records — encrypted, and searchable by type, condition, and year.",
"action": "Upload a first document",
"noMatchesTitle": "No matching documents",
"noMatchesDescription": "Nothing matches the current search and filters."
},
"detail": {
"viewOriginal": "View original",
"edit": "Edit",
"extract": "Extract facts",
"extracting": "Extracting…",
"discard": "Discard",
"notExtracted": "No facts extracted yet. Use Extract facts to read this document.",
"viewOriginalNewTab": "(opens in a new tab)",
"discardConfirmTitle": "Discard this document?",
"discardConfirmBody": "This permanently deletes the document, its original file, and any staged facts. This can't be undone.",
"discardConfirmAction": "Discard",
"discardCancel": "Cancel"
},
"edit": {
"title": "Edit document",
"subtitle": "Rename, recategorise, or set the document date.",
"title": "Document details",
"loadError": "Couldn't load this document.",
"saveError": "Couldn't save the change.",
"deleteError": "Couldn't delete the document.",
"download": "Download",
"delete": "Delete",
"titleLabel": "Title",
"kindLabel": "Category",
"titlePlaceholder": "Add a title",
"kindLabel": "Type",
"dateLabel": "Document date",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel"
},
"note": {
"providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically.",
"alreadyConfirmed": "This document has already been reviewed, so it can't be extracted again."
},
"committed": {
"title": "Approved from this document"
"conditionsLabel": "Linked conditions",
"linkCondition": "Link",
"noConditions": "Not linked to a condition",
"previewUnavailable": "No inline preview for this file type",
"uploadedMeta": "Added {date} · {size}"
}
},
"selfDescription": {
+64 -119
View File
@@ -8015,19 +8015,8 @@
},
"documents": {
"title": "Documentos",
"subtitle": "Guarda tus documentos clínicos y revisa lo que dicen.",
"upload": {
"label": "Añadir un documento",
"kindLabel": "Categoría",
"button": "Subir",
"uploading": "Subiendo…",
"fileLabel": "Archivo (obligatorio)",
"titleLabel": "Título (opcional)",
"titlePlaceholder": "p. ej. Informe de cardiología",
"dateLabel": "Fecha del documento (opcional)",
"fileRequired": "Elige un archivo para subir.",
"accepts": "JPEG, PNG, WebP o PDF · hasta 12 MB"
},
"subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.",
"pageUpload": "Subir",
"kind": {
"DOCTOR_REPORT": "Informe médico",
"DISCHARGE_LETTER": "Informe de alta",
@@ -8039,121 +8028,77 @@
"INSURANCE": "Seguro",
"VACCINATION": "Vacunación"
},
"list": {
"empty": "Aún no hay documentos.",
"noResults": "Ningún documento coincide con tus filtros.",
"loadMore": "Cargar más",
"loadingMore": "Cargando…",
"pending": "{count} por revisar",
"facts": "{count} datos",
"clearFilters": "Borrar filtros",
"loadError": "No se pudieron cargar tus documentos."
"filter": {
"searchLabel": "Buscar documentos",
"searchPlaceholder": "Buscar por título o nombre de archivo…",
"groupLabel": "Filtrar documentos",
"typeGroup": "Tipo de documento",
"conditionGroup": "Afección",
"yearGroup": "Año",
"clear": "Borrar filtros"
},
"status": {
"EXTRACTING": "Leyendo",
"EXTRACTED": "Listo para revisar",
"FAILED": "No se pudo leer",
"CONFIRMED": "Revisado",
"DISCARDED": "Descartado",
"STORED": "Guardado"
"card": {
"untitled": "Documento sin título",
"selectLabel": "Seleccionar {title}",
"openLabel": "Abrir {title}",
"attachmentBadge": "Solo descarga",
"uploading": "Subiendo…",
"dismissFailed": "Descartar subida fallida"
},
"review": {
"title": "Revisar datos extraídos",
"confirm": "Guardar datos aprobados",
"confirming": "Guardando…",
"needsReview": "Requiere revisión",
"approve": "Aprobar",
"reject": "Rechazar",
"edit": "Editar",
"cancel": "Cancelar",
"save": "Guardar",
"needsReviewHint": "Edita este dato para poder aprobarlo.",
"confidenceLabel": "Confianza de la extracción",
"commitError": {
"unitMismatch": "la unidad no coincide con la guardada",
"unitRequired": "un valor numérico necesita una unidad",
"generic": "no se pudo guardar"
}
},
"empty": {
"noFacts": "Nada más que revisar."
},
"factType": {
"CONDITION": "Diagnóstico",
"OBSERVATION": "Valor",
"MEDICATION_STATEMENT": "Medicación"
},
"fields": {
"label": "Nombre",
"value": "Valor",
"unit": "Unidad",
"date": "Fecha",
"dose": "Dosis"
"error": {
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
"quotaExceeded": "El almacenamiento está lleno — {used} de {quota} usados.",
"unsupportedType": "Este tipo de archivo no es compatible.",
"purged": "Este documento se eliminó definitivamente y no se puede restaurar.",
"duplicateExists": "Ya hay guardado un documento idéntico.",
"rateLimited": "Demasiadas subidas — inténtalo de nuevo en unos minutos.",
"generic": "La subida falló. Inténtalo de nuevo."
},
"toast": {
"uploaded": "Documento guardado.",
"uploadFailed": "No se pudo guardar el documento.",
"confirmed": "Datos aprobados guardados.",
"confirmedPartial": "Guardado, pero {count} no se pudieron guardar: {reason}.",
"confirmFailed": "No se pudieron guardar tus decisiones.",
"saved": "Dato actualizado.",
"discarded": "Documento descartado.",
"extracted": "Datos extraídos. Revísalos abajo.",
"extractFailed": "No se pudieron extraer los datos.",
"localOcrDisabled": "Activa el OCR local en los ajustes para leer este documento en el dispositivo.",
"updated": "Documento actualizado.",
"updateFailed": "No se pudo actualizar el documento.",
"discardFailed": "No se pudo descartar el documento.",
"uploadTooLarge": "El archivo es demasiado grande (máx. 12 MB).",
"uploadFileType": "Sube un archivo JPEG, PNG, WebP o PDF.",
"uploadRateLimited": "Demasiadas subidas. Inténtalo de nuevo más tarde.",
"uploadInvalidMetadata": "Revisa el título, la categoría o la fecha e inténtalo de nuevo."
"duplicate": "Ya estaba guardado — se resalta el documento existente.",
"deleted": "Documento eliminado.",
"restoreFailed": "No se pudo restaurar el documento."
},
"toolbar": {
"searchLabel": "Buscar",
"searchPlaceholder": "Título o nombre de archivo",
"kindFilterLabel": "Categoría",
"allKinds": "Todas las categorías",
"sortLabel": "Ordenar por",
"sort": {
"documentDate": "Fecha del documento",
"createdAt": "Fecha de adición",
"title": "Título"
},
"orderAsc": "Ascendente",
"orderDesc": "Descendente",
"fromLabel": "Desde",
"toLabel": "Hasta"
"timeline": {
"loadingMore": "Cargando más…"
},
"upload": {
"zoneTitle": "Suelta archivos aquí o elige desde tu dispositivo",
"zoneHint": "PDF, fotos y archivos de Office de hasta {maxSize} cada uno",
"browse": "Elegir archivos",
"quotaAlmostFull": "Almacenamiento casi lleno",
"quotaUsed": "{used} de {quota} usados"
},
"selection": {
"count": "{count} seleccionados",
"clear": "Borrar selección"
},
"list": {
"loadError": "No se pudieron cargar tus documentos."
},
"empty": {
"title": "Tu archivo de documentos está vacío",
"description": "Guarda cartas médicas, resultados de laboratorio, imágenes, recetas y certificados de vacunación — cifrados y con búsqueda por tipo, afección y año.",
"action": "Subir un primer documento",
"noMatchesTitle": "No hay documentos coincidentes",
"noMatchesDescription": "Nada coincide con la búsqueda y los filtros actuales."
},
"detail": {
"viewOriginal": "Ver original",
"edit": "Editar",
"extract": "Extraer datos",
"extracting": "Extrayendo…",
"discard": "Descartar",
"notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento.",
"viewOriginalNewTab": "(se abre en una pestaña nueva)",
"discardConfirmTitle": "¿Descartar este documento?",
"discardConfirmBody": "Esto elimina de forma permanente el documento, su archivo original y los datos preparados. No se puede deshacer.",
"discardConfirmAction": "Descartar",
"discardCancel": "Cancelar"
},
"edit": {
"title": "Editar documento",
"subtitle": "Renombra, recategoriza o fija la fecha del documento.",
"title": "Detalles del documento",
"loadError": "No se pudo cargar este documento.",
"saveError": "No se pudo guardar el cambio.",
"deleteError": "No se pudo eliminar el documento.",
"download": "Descargar",
"delete": "Eliminar",
"titleLabel": "Título",
"kindLabel": "Categoría",
"titlePlaceholder": "Añadir un título",
"kindLabel": "Tipo",
"dateLabel": "Fecha del documento",
"save": "Guardar",
"saving": "Guardando…",
"cancel": "Cancelar"
},
"note": {
"providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente.",
"alreadyConfirmed": "Este documento ya se ha revisado, por lo que no se puede volver a extraer."
},
"committed": {
"title": "Aprobado de este documento"
"conditionsLabel": "Afecciones vinculadas",
"linkCondition": "Vincular",
"noConditions": "Sin vincular a una afección",
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
"uploadedMeta": "Añadido el {date} · {size}"
}
},
"selfDescription": {
+64 -119
View File
@@ -8015,19 +8015,8 @@
},
"documents": {
"title": "Documents",
"subtitle": "Conservez vos documents médicaux et vérifiez ce qu'ils contiennent.",
"upload": {
"label": "Ajouter un document",
"kindLabel": "Catégorie",
"button": "Téléverser",
"uploading": "Téléversement…",
"fileLabel": "Fichier (requis)",
"titleLabel": "Titre (facultatif)",
"titlePlaceholder": "p. ex. Compte rendu de cardiologie",
"dateLabel": "Date du document (facultatif)",
"fileRequired": "Choisissez un fichier à téléverser.",
"accepts": "JPEG, PNG, WebP ou PDF · jusqu'à 12 Mo"
},
"subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.",
"pageUpload": "Téléverser",
"kind": {
"DOCTOR_REPORT": "Compte rendu médical",
"DISCHARGE_LETTER": "Lettre de sortie",
@@ -8039,121 +8028,77 @@
"INSURANCE": "Assurance",
"VACCINATION": "Vaccination"
},
"list": {
"empty": "Aucun document pour l'instant.",
"noResults": "Aucun document ne correspond à vos filtres.",
"loadMore": "Charger plus",
"loadingMore": "Chargement…",
"pending": "{count} à vérifier",
"facts": "{count} éléments",
"clearFilters": "Effacer les filtres",
"loadError": "Impossible de charger vos documents."
"filter": {
"searchLabel": "Rechercher des documents",
"searchPlaceholder": "Rechercher un titre ou un nom de fichier…",
"groupLabel": "Filtrer les documents",
"typeGroup": "Type de document",
"conditionGroup": "Affection",
"yearGroup": "Année",
"clear": "Effacer les filtres"
},
"status": {
"EXTRACTING": "Lecture",
"EXTRACTED": "Prêt à vérifier",
"FAILED": "Lecture impossible",
"CONFIRMED": "Vérifié",
"DISCARDED": "Écarté",
"STORED": "Conservé"
"card": {
"untitled": "Document sans titre",
"selectLabel": "Sélectionner {title}",
"openLabel": "Ouvrir {title}",
"attachmentBadge": "Téléchargement uniquement",
"uploading": "Téléversement…",
"dismissFailed": "Ignorer le téléversement échoué"
},
"review": {
"title": "Vérifier les données extraites",
"confirm": "Enregistrer les données approuvées",
"confirming": "Enregistrement…",
"needsReview": "À vérifier",
"approve": "Approuver",
"reject": "Rejeter",
"edit": "Modifier",
"cancel": "Annuler",
"save": "Enregistrer",
"needsReviewHint": "Modifie cette donnée pour pouvoir la valider.",
"confidenceLabel": "Confiance d'extraction",
"commitError": {
"unitMismatch": "l'unité ne correspond pas à celle enregistrée",
"unitRequired": "une valeur numérique nécessite une unité",
"generic": "il n'a pas pu être enregistré"
}
},
"empty": {
"noFacts": "Plus rien à vérifier."
},
"factType": {
"CONDITION": "Diagnostic",
"OBSERVATION": "Valeur",
"MEDICATION_STATEMENT": "Médicament"
},
"fields": {
"label": "Nom",
"value": "Valeur",
"unit": "Unité",
"date": "Date",
"dose": "Dose"
"error": {
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
"quotaExceeded": "Le stockage est plein — {used} sur {quota} utilisés.",
"unsupportedType": "Ce type de fichier n'est pas pris en charge.",
"purged": "Ce document a été définitivement supprimé et ne peut pas être restauré.",
"duplicateExists": "Un document identique est déjà enregistré.",
"rateLimited": "Trop de téléversements — réessayez dans quelques minutes.",
"generic": "Le téléversement a échoué. Veuillez réessayer."
},
"toast": {
"uploaded": "Document enregistré.",
"uploadFailed": "Impossible d'enregistrer le document.",
"confirmed": "Données approuvées enregistrées.",
"confirmedPartial": "Enregistré, mais {count} n'ont pas pu être enregistrés — {reason}.",
"confirmFailed": "Impossible d'enregistrer vos décisions.",
"saved": "Donnée mise à jour.",
"discarded": "Document écarté.",
"extracted": "Éléments extraits. Vérifiez-les ci-dessous.",
"extractFailed": "Impossible d'extraire les éléments.",
"localOcrDisabled": "Activez l'OCR local dans les paramètres pour lire ce document sur l'appareil.",
"updated": "Document mis à jour.",
"updateFailed": "Impossible de mettre à jour le document.",
"discardFailed": "Impossible de supprimer le document.",
"uploadTooLarge": "Le fichier est trop volumineux (max. 12 Mo).",
"uploadFileType": "Importe un fichier JPEG, PNG, WebP ou PDF.",
"uploadRateLimited": "Trop d'envois. Réessaie plus tard.",
"uploadInvalidMetadata": "Vérifie le titre, la catégorie ou la date, puis réessaie."
"duplicate": "Déjà enregistré — le document existant est mis en évidence.",
"deleted": "Document supprimé.",
"restoreFailed": "Impossible de restaurer le document."
},
"toolbar": {
"searchLabel": "Rechercher",
"searchPlaceholder": "Titre ou nom de fichier",
"kindFilterLabel": "Catégorie",
"allKinds": "Toutes les catégories",
"sortLabel": "Trier par",
"sort": {
"documentDate": "Date du document",
"createdAt": "Date d'ajout",
"title": "Titre"
},
"orderAsc": "Croissant",
"orderDesc": "Décroissant",
"fromLabel": "Du",
"toLabel": "Au"
"timeline": {
"loadingMore": "Chargement…"
},
"upload": {
"zoneTitle": "Déposez des fichiers ici ou choisissez depuis votre appareil",
"zoneHint": "PDF, photos et fichiers Office jusqu'à {maxSize} chacun",
"browse": "Choisir des fichiers",
"quotaAlmostFull": "Stockage presque plein",
"quotaUsed": "{used} sur {quota} utilisés"
},
"selection": {
"count": "{count} sélectionnés",
"clear": "Effacer la sélection"
},
"list": {
"loadError": "Impossible de charger vos documents."
},
"empty": {
"title": "Votre coffre à documents est vide",
"description": "Conservez courriers médicaux, résultats de laboratoire, imagerie, ordonnances et carnets de vaccination — chiffrés et consultables par type, affection et année.",
"action": "Téléverser un premier document",
"noMatchesTitle": "Aucun document correspondant",
"noMatchesDescription": "Rien ne correspond à la recherche et aux filtres actuels."
},
"detail": {
"viewOriginal": "Voir l'original",
"edit": "Modifier",
"extract": "Extraire les éléments",
"extracting": "Extraction…",
"discard": "Écarter",
"notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document.",
"viewOriginalNewTab": "(s'ouvre dans un nouvel onglet)",
"discardConfirmTitle": "Supprimer ce document ?",
"discardConfirmBody": "Cela supprime définitivement le document, son fichier d'origine et les données préparées. Cette action est irréversible.",
"discardConfirmAction": "Supprimer",
"discardCancel": "Annuler"
},
"edit": {
"title": "Modifier le document",
"subtitle": "Renommez, recatégorisez ou définissez la date du document.",
"title": "Détails du document",
"loadError": "Impossible de charger ce document.",
"saveError": "Impossible d'enregistrer la modification.",
"deleteError": "Impossible de supprimer le document.",
"download": "Télécharger",
"delete": "Supprimer",
"titleLabel": "Titre",
"kindLabel": "Catégorie",
"titlePlaceholder": "Ajouter un titre",
"kindLabel": "Type",
"dateLabel": "Date du document",
"save": "Enregistrer",
"saving": "Enregistrement…",
"cancel": "Annuler"
},
"note": {
"providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement.",
"alreadyConfirmed": "Ce document a déjà été vérifié ; il ne peut pas être extrait à nouveau."
},
"committed": {
"title": "Validé depuis ce document"
"conditionsLabel": "Affections liées",
"linkCondition": "Lier",
"noConditions": "Lié à aucune affection",
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
"uploadedMeta": "Ajouté le {date} · {size}"
}
},
"selfDescription": {
+64 -119
View File
@@ -8015,19 +8015,8 @@
},
"documents": {
"title": "Documenti",
"subtitle": "Conserva i tuoi documenti clinici e controlla cosa contengono.",
"upload": {
"label": "Aggiungi un documento",
"kindLabel": "Categoria",
"button": "Carica",
"uploading": "Caricamento…",
"fileLabel": "File (obbligatorio)",
"titleLabel": "Titolo (facoltativo)",
"titlePlaceholder": "es. Referto di cardiologia",
"dateLabel": "Data del documento (facoltativa)",
"fileRequired": "Scegli un file da caricare.",
"accepts": "JPEG, PNG, WebP o PDF · fino a 12 MB"
},
"subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.",
"pageUpload": "Carica",
"kind": {
"DOCTOR_REPORT": "Referto medico",
"DISCHARGE_LETTER": "Lettera di dimissione",
@@ -8039,121 +8028,77 @@
"INSURANCE": "Assicurazione",
"VACCINATION": "Vaccinazione"
},
"list": {
"empty": "Nessun documento ancora.",
"noResults": "Nessun documento corrisponde ai filtri.",
"loadMore": "Carica altri",
"loadingMore": "Caricamento…",
"pending": "{count} da rivedere",
"facts": "{count} dati",
"clearFilters": "Cancella i filtri",
"loadError": "Impossibile caricare i tuoi documenti."
"filter": {
"searchLabel": "Cerca documenti",
"searchPlaceholder": "Cerca titolo o nome file…",
"groupLabel": "Filtra documenti",
"typeGroup": "Tipo di documento",
"conditionGroup": "Patologia",
"yearGroup": "Anno",
"clear": "Cancella filtri"
},
"status": {
"EXTRACTING": "Lettura",
"EXTRACTED": "Pronto per la revisione",
"FAILED": "Lettura non riuscita",
"CONFIRMED": "Rivisto",
"DISCARDED": "Scartato",
"STORED": "Salvato"
"card": {
"untitled": "Documento senza titolo",
"selectLabel": "Seleziona {title}",
"openLabel": "Apri {title}",
"attachmentBadge": "Solo download",
"uploading": "Caricamento…",
"dismissFailed": "Ignora caricamento non riuscito"
},
"review": {
"title": "Rivedi i dati estratti",
"confirm": "Salva i dati approvati",
"confirming": "Salvataggio…",
"needsReview": "Da rivedere",
"approve": "Approva",
"reject": "Rifiuta",
"edit": "Modifica",
"cancel": "Annulla",
"save": "Salva",
"needsReviewHint": "Modifica questo dato per poterlo approvare.",
"confidenceLabel": "Affidabilità dell'estrazione",
"commitError": {
"unitMismatch": "l'unità non corrisponde a quella salvata",
"unitRequired": "un valore numerico richiede un'unità",
"generic": "non è stato possibile salvarlo"
}
},
"empty": {
"noFacts": "Niente altro da rivedere."
},
"factType": {
"CONDITION": "Diagnosi",
"OBSERVATION": "Valore",
"MEDICATION_STATEMENT": "Farmaco"
},
"fields": {
"label": "Nome",
"value": "Valore",
"unit": "Unità",
"date": "Data",
"dose": "Dose"
"error": {
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
"quotaExceeded": "Lo spazio è pieno — {used} di {quota} usati.",
"unsupportedType": "Questo tipo di file non è supportato.",
"purged": "Questo documento è stato rimosso definitivamente e non può essere ripristinato.",
"duplicateExists": "Un documento identico è già archiviato.",
"rateLimited": "Troppi caricamenti — riprova tra qualche minuto.",
"generic": "Caricamento non riuscito. Riprova."
},
"toast": {
"uploaded": "Documento salvato.",
"uploadFailed": "Impossibile salvare il documento.",
"confirmed": "Dati approvati salvati.",
"confirmedPartial": "Salvato, ma {count} non è stato possibile salvare: {reason}.",
"confirmFailed": "Impossibile salvare le tue scelte.",
"saved": "Dato aggiornato.",
"discarded": "Documento scartato.",
"extracted": "Dati estratti. Rivedili qui sotto.",
"extractFailed": "Impossibile estrarre i dati.",
"localOcrDisabled": "Attiva l'OCR locale nelle impostazioni per leggere questo documento sul dispositivo.",
"updated": "Documento aggiornato.",
"updateFailed": "Impossibile aggiornare il documento.",
"discardFailed": "Impossibile eliminare il documento.",
"uploadTooLarge": "Il file è troppo grande (max 12 MB).",
"uploadFileType": "Carica un file JPEG, PNG, WebP o PDF.",
"uploadRateLimited": "Troppi caricamenti. Riprova più tardi.",
"uploadInvalidMetadata": "Controlla titolo, categoria o data e riprova."
"duplicate": "Già archiviato — il documento esistente viene evidenziato.",
"deleted": "Documento eliminato.",
"restoreFailed": "Impossibile ripristinare il documento."
},
"toolbar": {
"searchLabel": "Cerca",
"searchPlaceholder": "Titolo o nome file",
"kindFilterLabel": "Categoria",
"allKinds": "Tutte le categorie",
"sortLabel": "Ordina per",
"sort": {
"documentDate": "Data del documento",
"createdAt": "Data di aggiunta",
"title": "Titolo"
},
"orderAsc": "Crescente",
"orderDesc": "Decrescente",
"fromLabel": "Dal",
"toLabel": "Al"
"timeline": {
"loadingMore": "Caricamento…"
},
"upload": {
"zoneTitle": "Trascina i file qui o scegli dal dispositivo",
"zoneHint": "PDF, foto e file Office fino a {maxSize} ciascuno",
"browse": "Scegli file",
"quotaAlmostFull": "Spazio quasi pieno",
"quotaUsed": "{used} di {quota} usati"
},
"selection": {
"count": "{count} selezionati",
"clear": "Annulla selezione"
},
"list": {
"loadError": "Impossibile caricare i tuoi documenti."
},
"empty": {
"title": "Il tuo archivio documenti è vuoto",
"description": "Conserva lettere mediche, referti di laboratorio, imaging, prescrizioni e certificati vaccinali — cifrati e ricercabili per tipo, patologia e anno.",
"action": "Carica un primo documento",
"noMatchesTitle": "Nessun documento corrispondente",
"noMatchesDescription": "Nulla corrisponde alla ricerca e ai filtri attuali."
},
"detail": {
"viewOriginal": "Vedi originale",
"edit": "Modifica",
"extract": "Estrai dati",
"extracting": "Estrazione…",
"discard": "Scarta",
"notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento.",
"viewOriginalNewTab": "(si apre in una nuova scheda)",
"discardConfirmTitle": "Eliminare questo documento?",
"discardConfirmBody": "Questo elimina definitivamente il documento, il file originale e i dati preparati. L'operazione non è reversibile.",
"discardConfirmAction": "Elimina",
"discardCancel": "Annulla"
},
"edit": {
"title": "Modifica documento",
"subtitle": "Rinomina, ricategorizza o imposta la data del documento.",
"title": "Dettagli del documento",
"loadError": "Impossibile caricare questo documento.",
"saveError": "Impossibile salvare la modifica.",
"deleteError": "Impossibile eliminare il documento.",
"download": "Scarica",
"delete": "Elimina",
"titleLabel": "Titolo",
"kindLabel": "Categoria",
"titlePlaceholder": "Aggiungi un titolo",
"kindLabel": "Tipo",
"dateLabel": "Data del documento",
"save": "Salva",
"saving": "Salvataggio…",
"cancel": "Annulla"
},
"note": {
"providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente.",
"alreadyConfirmed": "Questo documento è già stato esaminato, quindi non può essere estratto di nuovo."
},
"committed": {
"title": "Approvato da questo documento"
"conditionsLabel": "Patologie collegate",
"linkCondition": "Collega",
"noConditions": "Non collegato a una patologia",
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
"uploadedMeta": "Aggiunto il {date} · {size}"
}
},
"selfDescription": {
+64 -119
View File
@@ -8015,19 +8015,8 @@
},
"documents": {
"title": "Dokumenty",
"subtitle": "Przechowuj swoje dokumenty medyczne i sprawdzaj, co zawierają.",
"upload": {
"label": "Dodaj dokument",
"kindLabel": "Kategoria",
"button": "Prześlij",
"uploading": "Przesyłanie…",
"fileLabel": "Plik (wymagany)",
"titleLabel": "Tytuł (opcjonalnie)",
"titlePlaceholder": "np. Raport kardiologiczny",
"dateLabel": "Data dokumentu (opcjonalnie)",
"fileRequired": "Wybierz plik do przesłania.",
"accepts": "JPEG, PNG, WebP lub PDF · do 12 MB"
},
"subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.",
"pageUpload": "Prześlij",
"kind": {
"DOCTOR_REPORT": "Raport lekarski",
"DISCHARGE_LETTER": "Wypis ze szpitala",
@@ -8039,121 +8028,77 @@
"INSURANCE": "Ubezpieczenie",
"VACCINATION": "Szczepienie"
},
"list": {
"empty": "Brak dokumentów.",
"noResults": "Żaden dokument nie pasuje do filtrów.",
"loadMore": "Załaduj więcej",
"loadingMore": "Ładowanie…",
"pending": "{count} do przeglądu",
"facts": "{count} faktów",
"clearFilters": "Wyczyść filtry",
"loadError": "Nie udało się wczytać Twoich dokumentów."
"filter": {
"searchLabel": "Szukaj dokumentów",
"searchPlaceholder": "Szukaj tytułu lub nazwy pliku…",
"groupLabel": "Filtruj dokumenty",
"typeGroup": "Typ dokumentu",
"conditionGroup": "Schorzenie",
"yearGroup": "Rok",
"clear": "Wyczyść filtry"
},
"status": {
"EXTRACTING": "Odczyt",
"EXTRACTED": "Gotowy do przeglądu",
"FAILED": "Nie udało się odczytać",
"CONFIRMED": "Przejrzany",
"DISCARDED": "Odrzucony",
"STORED": "Zapisany"
"card": {
"untitled": "Dokument bez tytułu",
"selectLabel": "Zaznacz {title}",
"openLabel": "Otwórz {title}",
"attachmentBadge": "Tylko do pobrania",
"uploading": "Przesyłanie…",
"dismissFailed": "Odrzuć nieudane przesyłanie"
},
"review": {
"title": "Przejrzyj wyodrębnione dane",
"confirm": "Zapisz zatwierdzone dane",
"confirming": "Zapisywanie…",
"needsReview": "Wymaga przeglądu",
"approve": "Zatwierdź",
"reject": "Odrzuć",
"edit": "Edytuj",
"cancel": "Anuluj",
"save": "Zapisz",
"needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.",
"confidenceLabel": "Pewność rozpoznania",
"commitError": {
"unitMismatch": "jednostka nie zgadza się z zapisaną",
"unitRequired": "wartość liczbowa wymaga jednostki",
"generic": "nie udało się zapisać"
}
},
"empty": {
"noFacts": "Nic więcej do przejrzenia."
},
"factType": {
"CONDITION": "Diagnoza",
"OBSERVATION": "Wartość",
"MEDICATION_STATEMENT": "Lek"
},
"fields": {
"label": "Nazwa",
"value": "Wartość",
"unit": "Jednostka",
"date": "Data",
"dose": "Dawka"
"error": {
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
"quotaExceeded": "Miejsce jest pełne — użyto {used} z {quota}.",
"unsupportedType": "Ten typ pliku nie jest obsługiwany.",
"purged": "Ten dokument został trwale usunięty i nie można go przywrócić.",
"duplicateExists": "Identyczny dokument jest już zapisany.",
"rateLimited": "Zbyt wiele przesyłań — spróbuj ponownie za kilka minut.",
"generic": "Przesyłanie nie powiodło się. Spróbuj ponownie."
},
"toast": {
"uploaded": "Dokument zapisany.",
"uploadFailed": "Nie udało się zapisać dokumentu.",
"confirmed": "Zatwierdzone dane zapisane.",
"confirmedPartial": "Zapisano, ale {count} nie udało się zapisać — {reason}.",
"confirmFailed": "Nie udało się zapisać Twoich decyzji.",
"saved": "Dane zaktualizowane.",
"discarded": "Dokument odrzucony.",
"extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.",
"extractFailed": "Nie udało się wyodrębnić faktów.",
"localOcrDisabled": "Włącz lokalne OCR w ustawieniach, aby odczytać ten dokument na urządzeniu.",
"updated": "Dokument zaktualizowany.",
"updateFailed": "Nie udało się zaktualizować dokumentu.",
"discardFailed": "Nie udało się odrzucić dokumentu.",
"uploadTooLarge": "Plik jest za duży (maks. 12 MB).",
"uploadFileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
"uploadRateLimited": "Zbyt wiele przesłań. Spróbuj ponownie później.",
"uploadInvalidMetadata": "Sprawdź tytuł, kategorię lub datę i spróbuj ponownie."
"duplicate": "Już zapisano — istniejący dokument został wyróżniony.",
"deleted": "Dokument usunięty.",
"restoreFailed": "Nie udało się przywrócić dokumentu."
},
"toolbar": {
"searchLabel": "Szukaj",
"searchPlaceholder": "Tytuł lub nazwa pliku",
"kindFilterLabel": "Kategoria",
"allKinds": "Wszystkie kategorie",
"sortLabel": "Sortuj według",
"sort": {
"documentDate": "Data dokumentu",
"createdAt": "Data dodania",
"title": "Tytuł"
},
"orderAsc": "Rosnąco",
"orderDesc": "Malejąco",
"fromLabel": "Od",
"toLabel": "Do"
"timeline": {
"loadingMore": "Wczytywanie…"
},
"upload": {
"zoneTitle": "Upuść pliki tutaj lub wybierz z urządzenia",
"zoneHint": "PDF, zdjęcia i pliki Office do {maxSize} każdy",
"browse": "Wybierz pliki",
"quotaAlmostFull": "Miejsce prawie pełne",
"quotaUsed": "Użyto {used} z {quota}"
},
"selection": {
"count": "Zaznaczono: {count}",
"clear": "Wyczyść zaznaczenie"
},
"list": {
"loadError": "Nie udało się wczytać Twoich dokumentów."
},
"empty": {
"title": "Twój sejf dokumentów jest pusty",
"description": "Przechowuj pisma lekarskie, wyniki laboratoryjne, obrazowanie, recepty i zaświadczenia o szczepieniach — zaszyfrowane i przeszukiwalne według typu, schorzenia i roku.",
"action": "Prześlij pierwszy dokument",
"noMatchesTitle": "Brak pasujących dokumentów",
"noMatchesDescription": "Nic nie pasuje do bieżącego wyszukiwania i filtrów."
},
"detail": {
"viewOriginal": "Zobacz oryginał",
"edit": "Edytuj",
"extract": "Wyodrębnij fakty",
"extracting": "Wyodrębnianie…",
"discard": "Odrzuć",
"notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument.",
"viewOriginalNewTab": "(otwiera się w nowej karcie)",
"discardConfirmTitle": "Odrzucić ten dokument?",
"discardConfirmBody": "Spowoduje to trwałe usunięcie dokumentu, oryginalnego pliku i przygotowanych danych. Tej operacji nie można cofnąć.",
"discardConfirmAction": "Odrzuć",
"discardCancel": "Anuluj"
},
"edit": {
"title": "Edytuj dokument",
"subtitle": "Zmień nazwę, kategorię lub ustaw datę dokumentu.",
"title": "Szczegóły dokumentu",
"loadError": "Nie udało się wczytać tego dokumentu.",
"saveError": "Nie udało się zapisać zmiany.",
"deleteError": "Nie udało się usunąć dokumentu.",
"download": "Pobierz",
"delete": "Usuń",
"titleLabel": "Tytuł",
"kindLabel": "Kategoria",
"titlePlaceholder": "Dodaj tytuł",
"kindLabel": "Typ",
"dateLabel": "Data dokumentu",
"save": "Zapisz",
"saving": "Zapisywanie…",
"cancel": "Anuluj"
},
"note": {
"providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty.",
"alreadyConfirmed": "Ten dokument został już sprawdzony, więc nie można go ponownie wyodrębnić."
},
"committed": {
"title": "Zatwierdzone z tego dokumentu"
"conditionsLabel": "Powiązane schorzenia",
"linkCondition": "Powiąż",
"noConditions": "Niepowiązany ze schorzeniem",
"previewUnavailable": "Brak podglądu dla tego typu pliku",
"uploadedMeta": "Dodano {date} · {size}"
}
},
"selfDescription": {
+12 -39
View File
@@ -1,45 +1,18 @@
"use client";
import { Suspense } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { InboundDocumentsView } from "@/components/documents/inbound-documents-view";
import { DocumentsView } from "@/components/documents/documents-view";
/**
* v1.25.0 (W-DOCS-IN) — inbound clinical documents entry.
*
* Born-gated on the resolved `modules.inboundDocuments` flag from
* `GET /api/auth/me` (opt-in / default-off — the per-user opt-in AND the
* operator server-wide kill-switch). An unauthenticated visitor is bounced to
* login; an authenticated account without the module opted in is bounced home
* (the nav entry is already hidden for them, so this only catches a direct URL
* hit). Every `/api/documents/inbound/*` route also enforces the gate
* server-side, so this is a UX redirect, not the security boundary.
* The Dokumente vault — server shell only. The client surface owns the
* module gate, the URL-backed filter state (which needs `useSearchParams`,
* hence the Suspense boundary), the virtualized timeline, and the upload
* queue. The route rides the AuthShell's wide-container flag: the shell
* owns the page frame, this file never re-implements paddings.
*/
export default function DocumentsPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const enabled = user?.modules?.inboundDocuments === true;
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/");
}
}, [isLoading, isAuthenticated, enabled, router]);
if (isLoading || !isAuthenticated || !enabled) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return <InboundDocumentsView />;
return (
<Suspense fallback={null}>
<DocumentsView />
</Suspense>
);
}
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The vault page's designed states, pinned via single-pass static renders:
*
* - first load paints the skeleton grid (`documents-loading`) — a shape
* that matches the loaded card grid, never a bare spinner;
* - an empty unfiltered corpus paints the TEACHING empty state (what
* belongs here + an upload affordance), not the error copy;
* - an empty FILTERED view paints the no-matches state with a
* clear-filters affordance — a distinct state, per the standards a
* failed query may never fall through to either.
*/
let mockSearch = "";
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
usePathname: () => "/documents",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: { inboundDocuments: true } },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
// Never resolves within the synchronous SSR render → queries whose
// cache is not seeded stay pending.
apiGet: () => new Promise(() => {}),
apiPatch: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { DocumentsView } from "../documents-view";
function render(search: string, seedList?: { kinds?: never } | object) {
mockSearch = search;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (seedList !== undefined) {
queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), {
pages: [{ documents: [], nextCursor: null }],
pageParams: [null],
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<DocumentsView />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<DocumentsView> states", () => {
it("first load: skeleton grid matching the card layout, no spinner", () => {
const html = render("");
expect(html).toContain('data-slot="documents-loading"');
expect(html).toContain('data-slot="skeleton"');
expect(html).toContain('data-slot="document-filter-bar"');
expect(html).toContain('data-slot="document-upload-zone"');
// A pending list must never read as an empty vault.
expect(html).not.toContain("Your document vault is empty");
});
it("empty unfiltered corpus: the teaching empty state with an upload CTA", () => {
const html = render("", {});
expect(html).toContain('data-slot="empty-state"');
expect(html).toContain("Your document vault is empty");
expect(html).toContain("Upload a first document");
expect(html).not.toContain("No matching documents");
});
it("empty filtered view: the distinct no-matches state with clear-filters", () => {
const html = render("kind=IMAGING", { kinds: ["IMAGING"] });
expect(html).toContain("No matching documents");
expect(html).toContain("Clear filters");
expect(html).not.toContain("Your document vault is empty");
});
});
@@ -0,0 +1,202 @@
"use client";
/**
* The vault's sticky filter rail: debounced title/filename search, the
* nine-kind type-chip row (multi-select, OR inside the facet), condition
* chips (episodes that actually carry links), a year segmenter (years
* present in the corpus), and a one-tap clear with the active-facet count.
*
* Purely presentational — the filter state lives in the page URL and is
* owned by `documents-view.tsx`; this bar only renders the controls. It
* sticks below the top edge inside the shell's scroll container (sticky,
* translucent background, border-b — no viewport-height tricks).
*/
import { Search, X } from "lucide-react";
import type { RefObject } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
/** Shared chip chrome — mirrors the app-wide FilterBar pill vocabulary. */
const CHIP_CLASSES =
"border-border bg-card text-foreground inline-flex min-h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none";
const CHIP_ACTIVE_CLASSES = "border-primary/40 bg-primary/10";
export interface ConditionChip {
episodeId: string;
name: string;
}
export function DocumentFilterBar({
searchValue,
onSearchChange,
searchInputRef,
activeKinds,
onToggleKind,
conditionChips,
activeEpisodeId,
onToggleEpisode,
years,
activeYear,
onToggleYear,
activeCount,
onClearAll,
}: {
searchValue: string;
onSearchChange: (value: string) => void;
/** Focused by the page-level `/` shortcut. */
searchInputRef: RefObject<HTMLInputElement | null>;
activeKinds: ReadonlySet<InboundDocumentKindValue>;
onToggleKind: (kind: InboundDocumentKindValue) => void;
conditionChips: ConditionChip[];
activeEpisodeId: string | undefined;
onToggleEpisode: (episodeId: string) => void;
years: number[];
activeYear: number | undefined;
onToggleYear: (year: number) => void;
activeCount: number;
onClearAll: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="document-filter-bar"
className="bg-background/95 border-border sticky top-0 z-10 -mx-4 border-b px-4 pt-1 pb-3 backdrop-blur md:-mx-6 md:px-6"
>
<div className="flex items-center gap-3">
<div className="relative w-full max-w-md">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
ref={searchInputRef}
type="search"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.filter.searchPlaceholder")}
aria-label={t("documents.filter.searchLabel")}
className="pr-8 pl-9"
aria-keyshortcuts="/"
/>
<kbd
aria-hidden
className="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-xs sm:inline-block"
>
/
</kbd>
</div>
{activeCount > 0 ? (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="text-muted-foreground shrink-0"
>
<X className="size-3.5" aria-hidden />
{t("documents.filter.clear")}
{activeCount > 1 ? (
<span className="tabular-nums">({activeCount})</span>
) : null}
</Button>
) : null}
</div>
<div
className="-mx-4 mt-3 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden"
role="group"
aria-label={t("documents.filter.groupLabel")}
>
<span
role="group"
aria-label={t("documents.filter.typeGroup")}
className="contents"
>
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
const active = activeKinds.has(kind);
return (
<button
key={kind}
type="button"
aria-pressed={active}
onClick={() => onToggleKind(kind)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<Icon className="size-3.5 shrink-0" aria-hidden />
{t(`documents.kind.${kind}`)}
</button>
);
})}
</span>
{conditionChips.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.conditionGroup")}
className="contents"
>
{conditionChips.map((chip) => {
const active = chip.episodeId === activeEpisodeId;
return (
<button
key={chip.episodeId}
type="button"
aria-pressed={active}
onClick={() => onToggleEpisode(chip.episodeId)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<span className="max-w-40 truncate">{chip.name}</span>
</button>
);
})}
</span>
</>
) : null}
{years.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.yearGroup")}
className="contents"
>
{years.map((year) => {
const active = year === activeYear;
return (
<button
key={year}
type="button"
aria-pressed={active}
onClick={() => onToggleYear(year)}
className={cn(
CHIP_CLASSES,
"tabular-nums",
active && CHIP_ACTIVE_CLASSES,
)}
>
{year}
</button>
);
})}
</span>
</>
) : null}
</div>
</div>
);
}
+414
View File
@@ -0,0 +1,414 @@
"use client";
/**
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
*
* Filter state lives in the URL (`?q&kind&episode&year`) so every view is
* shareable, back-button-safe, and deep-linkable from the illness page and
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
* virtualized; uploads appear optimistically above it in < 100 ms.
*
* Born-gated on the resolved `modules.inboundDocuments` flag from
* `GET /api/auth/me` (per-user opt-in AND the operator availability layer).
* An unauthenticated visitor bounces to login; an account without the
* module bounces home. Every `/api/documents/inbound/*` route re-enforces
* the gate server-side — this is a UX redirect, not the security boundary.
*/
import {
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { FolderOpen, Loader2, SearchX, Upload, X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
import type {
DocumentUsageDto,
InboundDocumentDetailDto,
InboundDocumentDto,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentDetailSheet } from "./document-detail-sheet";
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
import { UploadZone } from "./upload-zone";
import { useDocumentUpload } from "./use-document-upload";
import {
buildVaultListApiSearch,
countActiveFilters,
documentDateKey,
parseVaultSearchParams,
vaultFiltersToSearch,
} from "./vault-utils";
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function DocumentsView() {
const { t } = useTranslations();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const moduleEnabled = user?.modules?.inboundDocuments === true;
// UX redirect only — the API routes enforce the gate server-side.
useEffect(() => {
if (authLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!moduleEnabled) {
router.push("/");
}
}, [authLoading, isAuthenticated, moduleEnabled, router]);
// ── URL-owned filter state ────────────────────────────────────────────
const filters = useMemo(
() => parseVaultSearchParams(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const applyFilters = useCallback(
(next: DocumentVaultFilters, mode: "push" | "replace") => {
const search = vaultFiltersToSearch(next);
const href = search ? `${pathname}?${search}` : pathname;
if (mode === "push") {
router.push(href, { scroll: false });
} else {
router.replace(href, { scroll: false });
}
},
[pathname, router],
);
// Search draft debounces into the URL (200 ms); an external URL change
// (back button, deep link) re-seeds the draft — render-phase derived-state
// adjustment, not an effect.
const [searchDraft, setSearchDraft] = useState(filters.q ?? "");
const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? "");
const searchInputRef = useRef<HTMLInputElement | null>(null);
if ((filters.q ?? "") !== lastUrlQ) {
setLastUrlQ(filters.q ?? "");
setSearchDraft(filters.q ?? "");
}
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = searchDraft.trim();
if (trimmed === (filters.q ?? "")) return;
applyFilters(
{ ...filters, q: trimmed === "" ? undefined : trimmed },
"replace",
);
}, 200);
return () => clearTimeout(handle);
}, [searchDraft, filters, applyFilters]);
// `/` focuses the search from anywhere on the page.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey)
return;
const target = event.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
event.preventDefault();
searchInputRef.current?.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const toggleKind = (kind: InboundDocumentKindValue) => {
const current = new Set(filters.kinds ?? []);
if (current.has(kind)) {
current.delete(kind);
} else {
current.add(kind);
}
applyFilters(
{
...filters,
kinds: current.size > 0 ? [...current].sort() : undefined,
},
"push",
);
};
const toggleEpisode = (episodeId: string) => {
applyFilters(
{
...filters,
episodeId: filters.episodeId === episodeId ? undefined : episodeId,
},
"push",
);
};
const toggleYear = (year: number) => {
applyFilters(
{ ...filters, year: filters.year === year ? undefined : year },
"push",
);
};
const clearFilters = () => {
setSearchDraft("");
applyFilters({}, "push");
};
// ── Data ──────────────────────────────────────────────────────────────
const usage = useQuery({
queryKey: queryKeys.inboundDocumentUsage(),
enabled: moduleEnabled,
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
});
const list = useInfiniteQuery({
queryKey: queryKeys.inboundDocumentList(filters),
enabled: moduleEnabled,
queryFn: ({ pageParam }) =>
apiGet<ListPage>(
`/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`,
),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const documents = useMemo(
() => list.data?.pages.flatMap((p) => p.documents) ?? [],
[list.data],
);
const upload = useDocumentUpload(
usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined,
);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const episodes = useIllnessEpisodes(true);
// Condition chips: the user's episodes that actually carry links in the
// loaded corpus — plus the actively filtered episode (a deep link must
// always show its own chip, even before its documents load).
const conditionChips = useMemo<ConditionChip[]>(() => {
const byId = new Map<string, string>();
for (const doc of documents) {
for (const link of doc.conditionLinks) {
if (!byId.has(link.episodeId)) byId.set(link.episodeId, link.name);
}
}
if (filters.episodeId && !byId.has(filters.episodeId)) {
const episode = episodes.data?.find((e) => e.id === filters.episodeId);
byId.set(filters.episodeId, episode?.label ?? filters.episodeId);
}
return [...byId.entries()]
.map(([episodeId, name]) => ({ episodeId, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [documents, filters.episodeId, episodes.data]);
// Year segmenter: years present in the loaded corpus (+ the active year).
const years = useMemo(() => {
const set = new Set<number>();
for (const doc of documents) {
set.add(Number(documentDateKey(doc).slice(0, 4)));
}
if (filters.year !== undefined) set.add(filters.year);
return [...set].sort((a, b) => b - a);
}, [documents, filters.year]);
// ── Selection (bulk actions attach to this in a follow-up) ───────────
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
new Set(),
);
const toggleSelected = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// ── Detail sheet ──────────────────────────────────────────────────────
const [detailId, setDetailId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const openDetail = useCallback((id: string) => {
setDetailId(id);
setDetailOpen(true);
}, []);
// Hover/focus intent prefetches the detail METADATA (never the blob —
// the blob fetch starts when the sheet mounts its preview element).
const prefetchDetail = useCallback(
(id: string) => {
void queryClient.prefetchQuery({
queryKey: queryKeys.inboundDocument(id),
queryFn: () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${id}`),
staleTime: 30_000,
});
},
[queryClient],
);
if (authLoading || !isAuthenticated || !moduleEnabled) {
return (
<div className="flex h-64 items-center justify-center" role="status">
<Loader2
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
aria-hidden
/>
<span className="sr-only">{t("nav.loadingScreen")}</span>
</div>
);
}
const activeCount = countActiveFilters(filters);
const isFiltered = activeCount > 0;
const showEmpty =
list.isSuccess && documents.length === 0 && upload.items.length === 0;
return (
<div className="space-y-6">
<PageHeader
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
}
/>
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={(files) =>
upload.enqueue(files, { episodeId: filters.episodeId })
}
/>
<DocumentFilterBar
searchValue={searchDraft}
onSearchChange={setSearchDraft}
searchInputRef={searchInputRef}
activeKinds={new Set(filters.kinds ?? [])}
onToggleKind={toggleKind}
conditionChips={conditionChips}
activeEpisodeId={filters.episodeId}
onToggleEpisode={toggleEpisode}
years={years}
activeYear={filters.year}
onToggleYear={toggleYear}
activeCount={activeCount}
onClearAll={clearFilters}
/>
{selectedIds.size > 0 ? (
<div
data-slot="document-selection-bar"
className="flex items-center justify-between gap-3"
>
<p className="text-sm font-medium">
{t("documents.selection.count", { count: selectedIds.size })}
</p>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setSelectedIds(new Set())}
>
<X className="size-3.5" aria-hidden />
{t("documents.selection.clear")}
</Button>
</div>
) : null}
{list.isPending ? (
<div
data-slot="documents-loading"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{Array.from({ length: 9 }, (_, i) => (
<Skeleton key={i} className="h-28 rounded-xl" />
))}
</div>
) : list.isError ? (
<QueryErrorCard
title={t("documents.list.loadError")}
onRetry={() => void list.refetch()}
/>
) : showEmpty && !isFiltered ? (
<EmptyState
icon={<FolderOpen className="size-6" aria-hidden />}
title={t("documents.empty.title")}
description={t("documents.empty.description")}
ctaSize="lg"
action={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.empty.action")}
</Button>
}
/>
) : showEmpty && isFiltered ? (
<EmptyState
icon={<SearchX className="size-6" aria-hidden />}
title={t("documents.empty.noMatchesTitle")}
description={t("documents.empty.noMatchesDescription")}
action={
<Button variant="outline" onClick={clearFilters}>
{t("documents.filter.clear")}
</Button>
}
/>
) : (
<DocumentTimeline
documents={documents}
uploadItems={upload.items}
onDismissUpload={upload.dismiss}
hasNextPage={list.hasNextPage}
isFetchingNextPage={list.isFetchingNextPage}
onLoadMore={() => void list.fetchNextPage()}
selectedIds={selectedIds}
onToggleSelected={toggleSelected}
onOpen={openDetail}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>
)}
<DocumentDetailSheet
documentId={detailId}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
/**
* The vault's upload entry: a dashed drop-target card with a file button.
* The `accept` list comes verbatim from the usage endpoint (HEIC is
* deliberately absent so the iOS picker transcodes camera photos to JPEG),
* `multiple` is on, and there is NO `capture` attribute — mobile Safari
* then offers camera AND library. Dropping files onto the card feeds the
* same queue as the picker.
*
* The quota bar renders only above 80 % usage — a calm surface until
* storage actually becomes a topic.
*/
import { UploadCloud } from "lucide-react";
import { useRef, useState, type RefObject } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
import { formatBytes } from "./vault-utils";
/** Usage fraction above which the quota bar appears. */
const QUOTA_BAR_THRESHOLD = 0.8;
export function UploadZone({
usage,
onFiles,
inputRef,
}: {
/** Limits + usage from `GET /api/documents/inbound/usage` (undefined while loading). */
usage: DocumentUsageDto | undefined;
onFiles: (files: File[]) => void;
/** Exposed so the page header's Upload button can open the same picker. */
inputRef: RefObject<HTMLInputElement | null>;
}) {
const { t, locale } = useTranslations();
const [dragOver, setDragOver] = useState(false);
const dragDepth = useRef(0);
const accept = usage?.acceptedExtensions.join(",");
const usedFraction =
usage && usage.quotaBytes > 0 ? usage.usedBytes / usage.quotaBytes : 0;
const pickFiles = (list: FileList | null) => {
if (!list || list.length === 0) return;
onFiles(Array.from(list));
};
return (
<div data-slot="document-upload-zone" className="space-y-2">
<div
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current += 1;
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
pickFiles(e.dataTransfer.files);
}}
className={cn(
"border-border rounded-xl border-2 border-dashed px-4 py-6 text-center transition-colors",
dragOver && "border-primary bg-primary/5",
)}
>
<div className="flex flex-col items-center gap-2">
<UploadCloud
className={cn(
"text-muted-foreground size-6",
dragOver && "text-primary",
)}
aria-hidden
/>
<p className="text-sm font-medium">
{t("documents.upload.zoneTitle")}
</p>
{usage ? (
<p className="text-muted-foreground text-xs">
{t("documents.upload.zoneHint", {
maxSize: formatBytes(usage.maxFileBytes, locale),
})}
</p>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => inputRef.current?.click()}
>
{t("documents.upload.browse")}
</Button>
</div>
<input
ref={inputRef}
type="file"
multiple
accept={accept}
className="sr-only"
aria-label={t("documents.upload.browse")}
onChange={(e) => {
pickFiles(e.target.files);
// Allow re-selecting the same file (duplicate flow) immediately.
e.target.value = "";
}}
/>
</div>
{usage && usedFraction >= QUOTA_BAR_THRESHOLD ? (
<div data-slot="document-quota-bar" className="space-y-1">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{t("documents.upload.quotaAlmostFull")}
</p>
<p className="text-muted-foreground text-xs tabular-nums">
{t("documents.upload.quotaUsed", {
used: formatBytes(usage.usedBytes, locale),
quota: formatBytes(usage.quotaBytes, locale),
})}
</p>
</div>
<Progress
value={Math.min(100, usedFraction * 100)}
aria-label={t("documents.upload.quotaUsed", {
used: formatBytes(usage.usedBytes, locale),
quota: formatBytes(usage.quotaBytes, locale),
})}
/>
</div>
) : null}
</div>
);
}
+17 -1
View File
@@ -36,6 +36,13 @@ const PUBLIC_PATHS = [
"/c/",
];
// Routes that opt into the wide content container (`max-w-screen-2xl`
// instead of the standard `max-w-screen-xl`). The shell stays the single
// owner of the page frame — a wide page never re-implements paddings or
// margins; it is listed here instead. Currently: the Dokumente vault,
// whose multi-column document timeline earns the extra width.
const WIDE_CONTENT_PATHS = ["/documents"];
export function AuthShell({
children,
demoMode = false,
@@ -69,6 +76,9 @@ export function AuthShell({
pathname.startsWith("/about") ||
pathname.startsWith("/c/");
const isAdminPage = pathname.startsWith("/admin");
const isWidePage = WIDE_CONTENT_PATHS.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
const isOnboardingPage = pathname === "/onboarding";
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
@@ -315,7 +325,13 @@ export function AuthShell({
the desktop bottom-6 anchor), so the last line of content
can always scroll out from under the floating button.
*/}
<div className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6">
<div
className={
isWidePage
? "mx-auto max-w-screen-2xl px-4 pt-6 pb-20 md:px-6"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
{children}
</div>
</main>