mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
4014d77a21
commit
b8009d2e73
12 files changed
+1268
-754
No files matched your search
+64
-119
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user