diff --git a/messages/de.json b/messages/de.json index 3b8b1e11f..e87d07786 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8116,6 +8116,20 @@ "restored": "{count} Dokument(e) wiederhergestellt", "partialFailure": "{failed} von {total} konnten nicht aktualisiert werden", "failed": "Die Aktion konnte nicht ausgeführt werden" + }, + "episodeCard": { + "title": "Dokumente", + "link": "Verknüpfen", + "upload": "Hochladen", + "viewAll": "Alle anzeigen", + "emptyLine": "Noch keine Dokumente mit dieser Erkrankung verknüpft.", + "loadError": "Verknüpfte Dokumente konnten nicht geladen werden" + }, + "linkPicker": { + "title": "Dokumente verknüpfen", + "description": "Wähle Dokumente aus deinem Bestand, die zu dieser Erkrankung gehören.", + "searchPlaceholder": "Nach Titel oder Dateiname suchen", + "empty": "Keine Dokumente gefunden." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index 12b8182d1..b3cc97413 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8116,6 +8116,20 @@ "restored": "{count} document(s) restored", "partialFailure": "{failed} of {total} could not be updated", "failed": "The action could not be completed" + }, + "episodeCard": { + "title": "Documents", + "link": "Link", + "upload": "Upload", + "viewAll": "Show all", + "emptyLine": "No documents linked to this condition yet.", + "loadError": "Could not load the linked documents" + }, + "linkPicker": { + "title": "Link documents", + "description": "Pick documents from your vault that belong to this condition.", + "searchPlaceholder": "Search by title or filename", + "empty": "No documents found." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index e8074a138..d5e963770 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8116,6 +8116,20 @@ "restored": "{count} documento(s) restaurados", "partialFailure": "No se pudieron actualizar {failed} de {total}", "failed": "No se pudo completar la acción" + }, + "episodeCard": { + "title": "Documentos", + "link": "Vincular", + "upload": "Subir", + "viewAll": "Ver todos", + "emptyLine": "Aún no hay documentos vinculados a esta afección.", + "loadError": "No se pudieron cargar los documentos vinculados" + }, + "linkPicker": { + "title": "Vincular documentos", + "description": "Elige documentos de tu archivo que pertenezcan a esta afección.", + "searchPlaceholder": "Buscar por título o nombre de archivo", + "empty": "No se encontraron documentos." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index 30fb93689..242e6c17b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8116,6 +8116,20 @@ "restored": "{count} document(s) restaurés", "partialFailure": "Impossible de mettre à jour {failed} sur {total}", "failed": "L'action n'a pas pu être effectuée" + }, + "episodeCard": { + "title": "Documents", + "link": "Lier", + "upload": "Téléverser", + "viewAll": "Tout afficher", + "emptyLine": "Aucun document n'est encore lié à cette affection.", + "loadError": "Impossible de charger les documents liés" + }, + "linkPicker": { + "title": "Lier des documents", + "description": "Choisissez dans vos documents ceux qui concernent cette affection.", + "searchPlaceholder": "Rechercher par titre ou nom de fichier", + "empty": "Aucun document trouvé." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index 87e4e4b81..64ff437ad 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8116,6 +8116,20 @@ "restored": "{count} documenti ripristinati", "partialFailure": "Impossibile aggiornare {failed} su {total}", "failed": "Impossibile completare l'azione" + }, + "episodeCard": { + "title": "Documenti", + "link": "Collega", + "upload": "Carica", + "viewAll": "Mostra tutti", + "emptyLine": "Nessun documento ancora collegato a questa condizione.", + "loadError": "Impossibile caricare i documenti collegati" + }, + "linkPicker": { + "title": "Collega documenti", + "description": "Scegli i documenti del tuo archivio che riguardano questa condizione.", + "searchPlaceholder": "Cerca per titolo o nome del file", + "empty": "Nessun documento trovato." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index 50c6ba0f7..df81b83fc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8116,6 +8116,20 @@ "restored": "Przywrócono dokumenty: {count}", "partialFailure": "Nie udało się zaktualizować {failed} z {total}", "failed": "Nie udało się wykonać akcji" + }, + "episodeCard": { + "title": "Dokumenty", + "link": "Połącz", + "upload": "Prześlij", + "viewAll": "Pokaż wszystkie", + "emptyLine": "Brak dokumentów połączonych z tym schorzeniem.", + "loadError": "Nie udało się wczytać połączonych dokumentów" + }, + "linkPicker": { + "title": "Połącz dokumenty", + "description": "Wybierz dokumenty z archiwum, które dotyczą tego schorzenia.", + "searchPlaceholder": "Szukaj po tytule lub nazwie pliku", + "empty": "Nie znaleziono dokumentów." } }, "selfDescription": { diff --git a/src/components/documents/__tests__/episode-documents-card.test.tsx b/src/components/documents/__tests__/episode-documents-card.test.tsx new file mode 100644 index 000000000..c07b0b167 --- /dev/null +++ b/src/components/documents/__tests__/episode-documents-card.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The illness page's "Dokumente" section, pinned via static renders: + * module-gated (renders NOTHING when the account has not opted in), a + * quiet one-line affordance on an episode without documents (never a + * teaching empty state), and compact deep-linking rows when links exist. + */ + +let moduleEnabled = true; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules: { inboundDocuments: moduleEnabled } }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { EpisodeDocumentsCard } from "../episode-documents-card"; + +function doc(id: string, title: string): InboundDocumentDto { + return { + id, + kind: "IMAGING", + title, + filename: `${id}.pdf`, + mimeType: "application/pdf", + byteSize: 2048, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: "2025-10-04", + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [{ episodeId: "ep1", name: "Knee" }], + servingClass: "inline", + createdAt: "2025-10-05T08:00:00.000Z", + updatedAt: "2025-10-05T08:00:00.000Z", + }; +} + +function render(seed?: InboundDocumentDto[]) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + if (seed !== undefined) { + queryClient.setQueryData(queryKeys.inboundDocumentEpisodePreview("ep1"), { + documents: seed, + nextCursor: null, + }); + } + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + it("renders nothing when the documents module is off", () => { + moduleEnabled = false; + expect(render([])).toBe(""); + moduleEnabled = true; + }); + + it("shows a quiet one-line affordance on an episode without documents", () => { + const html = render([]); + expect(html).toContain('data-slot="episode-documents-card"'); + expect(html).toContain("No documents linked to this condition yet."); + expect(html).toContain("Link"); + expect(html).toContain("Upload"); + // Not the vault's teaching empty state. + expect(html).not.toContain('data-slot="empty-state"'); + }); + + it("lists linked documents as rows deep-linking into the vault", () => { + const html = render([doc("d1", "MRT Knie"), doc("d2", "Röntgen")]); + expect(html).toContain("MRT Knie"); + expect(html).toContain("Röntgen"); + expect(html).toContain("/documents?episode=ep1&doc=d1"); + // The pre-linked upload deep link carries the episode filter. + expect(html).toContain('href="/documents?episode=ep1"'); + }); +}); diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx new file mode 100644 index 000000000..d1e5ff6e3 --- /dev/null +++ b/src/components/documents/document-link-picker.tsx @@ -0,0 +1,194 @@ +"use client"; + +/** + * "Verknüpfen" — link existing vault documents to an illness episode from + * the episode's detail page. A `ResponsiveSheet` with a debounced search + * over the vault (title/filename, same recall path as the vault page) and + * one toggle row per document; toggling fires the bulk link/unlink action + * for that single id (idempotent by contract, no-op-success on + * already-in-state rows) and refreshes every document read through the + * `["documents"]` prefix. + * + * Shows one page (50) of matches — the search input is the way to reach an + * older document, mirroring the vault's own recall model. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Search } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Input } from "@/components/ui/input"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; +import type { + DocumentBulkResultDto, + InboundDocumentDto, +} from "@/lib/validations/inbound-documents"; +import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; +import { documentDateKey } from "./vault-utils"; + +interface ListPage { + documents: InboundDocumentDto[]; + nextCursor: string | null; +} + +export function DocumentLinkPicker({ + episodeId, + open, + onOpenChange, +}: { + episodeId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + const queryClient = useQueryClient(); + + const [searchDraft, setSearchDraft] = useState(""); + const [query, setQuery] = useState(""); + + // 200 ms debounce, matching the vault's search feel. + const debounceRef = useRef | null>(null); + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, + [], + ); + const onSearchChange = (value: string) => { + setSearchDraft(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setQuery(value.trim()), 200); + }; + + const list = useQuery({ + queryKey: queryKeys.inboundDocumentPicker(query), + enabled: open, + queryFn: () => { + const sp = new URLSearchParams(); + if (query) sp.set("q", query); + sp.set("sort", "documentDate"); + sp.set("order", "desc"); + sp.set("limit", "50"); + return apiGet(`/api/documents/inbound?${sp.toString()}`); + }, + }); + + const toggle = useMutation({ + mutationFn: (input: { documentId: string; linked: boolean }) => + apiPost<{ results: DocumentBulkResultDto[] }>( + "/api/documents/inbound/bulk", + { + ids: [input.documentId], + action: input.linked ? "unlinkEpisode" : "linkEpisode", + episodeId, + }, + ), + onSuccess: (data) => { + if (data.results.some((r) => !r.ok)) { + toast.error(t("documents.bulk.failed")); + } + }, + onError: () => toast.error(t("documents.bulk.failed")), + onSettled: () => { + void invalidateKeys(queryClient, [queryKeys.documents()]); + }, + }); + + const documents = list.data?.documents ?? []; + + return ( + +
+
+ + onSearchChange(e.target.value)} + placeholder={t("documents.linkPicker.searchPlaceholder")} + aria-label={t("documents.linkPicker.searchPlaceholder")} + className="pl-9" + /> +
+ + {list.isPending && open ? ( +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+ ) : list.isError ? ( +

+ {t("documents.list.loadError")} +

+ ) : documents.length === 0 ? ( +

+ {t("documents.linkPicker.empty")} +

+ ) : ( +
    + {documents.map((doc) => { + const linked = doc.conditionLinks.some( + (l) => l.episodeId === episodeId, + ); + const title = + doc.title ?? doc.filename ?? t("documents.card.untitled"); + const Icon = DOCUMENT_KIND_ICONS[doc.kind]; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index e5a3ef44c..6a34e00ca 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -462,6 +462,33 @@ export function DocumentsView() { setDetailOpen(true); }, []); + // `?doc=` deep link (used by the illness page's document rows): open + // the detail sheet once per value; closing the sheet strips the param so + // back/forward and refresh behave. The id is shape-checked before it is + // interpolated into an API path. + const docParam = searchParams.get("doc"); + const [consumedDocParam, setConsumedDocParam] = useState(null); + useEffect(() => { + if (!moduleEnabled || !docParam || docParam === consumedDocParam) return; + setConsumedDocParam(docParam); + if (/^[a-zA-Z0-9_-]{1,40}$/.test(docParam)) openDetail(docParam); + }, [docParam, consumedDocParam, moduleEnabled, openDetail]); + + const handleDetailOpenChange = useCallback( + (open: boolean) => { + setDetailOpen(open); + if (!open && searchParams.get("doc")) { + const sp = new URLSearchParams(searchParams.toString()); + sp.delete("doc"); + const search = sp.toString(); + router.replace(search ? `${pathname}?${search}` : pathname, { + scroll: false, + }); + } + }, + [pathname, router, searchParams], + ); + // Hover/focus intent prefetches the detail METADATA (never the blob — // the blob fetch starts when the sheet mounts its preview element). const prefetchDetail = useCallback( @@ -586,7 +613,7 @@ export function DocumentsView() { {selectedIds.size > 0 ? ( diff --git a/src/components/documents/episode-documents-card.tsx b/src/components/documents/episode-documents-card.tsx new file mode 100644 index 000000000..ad95953df --- /dev/null +++ b/src/components/documents/episode-documents-card.tsx @@ -0,0 +1,179 @@ +"use client"; + +/** + * The "Dokumente" section on an illness-episode detail page — the + * condition side of the condition ⇄ documents linking (the document side + * links back via the detail sheet's condition chips). Lists the episode's + * most recent linked documents as compact rows deep-linking straight into + * the vault (`?episode=…&doc=…` opens the detail sheet), plus the three + * affordances: link an existing document, upload pre-linked (deep link + * with the episode filter active — the vault threads it into the upload), + * and the filtered "show all" view. + * + * Renders nothing when the documents module is off for this account — + * module-gated surfaces never leak. An episode without documents shows a + * quiet one-line affordance, not a teaching empty state. + */ +import { useQuery } from "@tanstack/react-query"; +import { FolderOpen, Link2, Upload } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import { TileHeader } from "@/components/insights/tile-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ListRow } from "@/components/ui/list-row"; +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 { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { DocumentLinkPicker } from "./document-link-picker"; +import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; +import { documentDateKey } from "./vault-utils"; + +/** Compact preview size — "Alle anzeigen" carries the long tail. */ +const PREVIEW_LIMIT = 5; + +interface ListPage { + documents: InboundDocumentDto[]; + nextCursor: string | null; +} + +export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) { + const { t } = useTranslations(); + const format = useFormatters(); + const { user } = useAuth(); + const [pickerOpen, setPickerOpen] = useState(false); + + const moduleEnabled = user?.modules?.inboundDocuments === true; + + const list = useQuery({ + queryKey: queryKeys.inboundDocumentEpisodePreview(episodeId), + enabled: moduleEnabled, + queryFn: () => { + const sp = new URLSearchParams(); + sp.set("episodeId", episodeId); + sp.set("sort", "documentDate"); + sp.set("order", "desc"); + sp.set("limit", String(PREVIEW_LIMIT + 1)); + return apiGet(`/api/documents/inbound?${sp.toString()}`); + }, + }); + + if (!moduleEnabled) return null; + + if (list.isError) { + return ( + void list.refetch()} + /> + ); + } + + const documents = (list.data?.documents ?? []).slice(0, PREVIEW_LIMIT); + const hasMore = + (list.data?.documents.length ?? 0) > PREVIEW_LIMIT || + list.data?.nextCursor != null; + const vaultHref = `/documents?episode=${encodeURIComponent(episodeId)}`; + + return ( + <> + + + 0 ? ( + + {documents.length} + {hasMore ? "+" : ""} + + ) : undefined + } + /> + + {list.isPending ? ( +
+ {Array.from({ length: 2 }, (_, i) => ( + + ))} +
+ ) : documents.length === 0 ? ( +

+ {t("documents.episodeCard.emptyLine")} +

+ ) : ( +
    + {documents.map((doc) => { + const title = + doc.title ?? doc.filename ?? t("documents.card.untitled"); + const Icon = DOCUMENT_KIND_ICONS[doc.kind]; + return ( +
  • + + + + + {title} + + + {format.date(`${documentDateKey(doc)}T12:00:00.000Z`)} + + + +
  • + ); + })} +
+ )} + +
+ + + {hasMore ? ( + + ) : null} +
+
+
+ + + + ); +} diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 55f87541e..0f61ba6d3 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -10,6 +10,7 @@ */ import { useState } from "react"; +import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -143,6 +144,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { )} + {/* Condition ⇄ documents linking, condition side: the episode's + linked documents plus link/upload entries into the vault. Renders + nothing when the documents module is off for this account. */} + {episode ? : null} + {episode ? ( <> ["documents", "inbound", "list", filters] as const, inboundDocument: (id: string) => ["documents", "inbound", id] as const, + /** + * The illness-episode detail's compact document preview (single page, + * small limit). Deliberately NOT `inboundDocumentList({ episodeId })` — + * that key belongs to the vault's `useInfiniteQuery` and a plain query + * under the same key would poison the cache with a different shape. + */ + inboundDocumentEpisodePreview: (episodeId: string) => + ["documents", "inbound", "episode-preview", episodeId] as const, + /** + * The link-existing-documents picker (searchable single page). Own key + * for the same shape-collision reason as the episode preview. + */ + inboundDocumentPicker: (q: string) => + ["documents", "inbound", "picker", q] as const, /** * Storage usage + effective limits (`GET /api/documents/inbound/usage`). * Invalidated through the `["documents"]` prefix after every upload /