feat(documents): condition page lists and links its documents

The illness-episode detail gains a Dokumente card: the episode's most
recent linked documents as compact rows (deep-linking into the vault,
where ?doc= now opens the detail sheet directly), a link-existing picker
(searchable, toggle rows driving the idempotent bulk link/unlink), an
upload entry that lands pre-linked via the episode filter, and a
filtered show-all view. Module-gated — accounts without the documents
module see nothing. An episode without documents keeps a quiet one-line
affordance instead of an empty card. The preview and picker reads get
their own query keys; reusing the vault list key would collide with the
infinite-query shape.

Refs: document-vault plan W3-T4
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 13:28:54 +02:00
1 parent 8f442c3d6c
commit ed2ac8b61d
12 files changed
+608 -1

No files matched your search

+14
View File
@@ -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": {
+14
View File
@@ -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": {
+14
View File
@@ -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": {
+14
View File
@@ -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": {
+14
View File
@@ -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": {
+14
View File
@@ -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": {
@@ -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<typeof import("@/lib/api/api-fetch")>(
"@/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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<EpisodeDocumentsCard episodeId="ep1" />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<EpisodeDocumentsCard>", () => {
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&amp;doc=d1");
// The pre-linked upload deep link carries the episode filter.
expect(html).toContain('href="/documents?episode=ep1"');
});
});
@@ -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<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const onSearchChange = (value: string) => {
setSearchDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => 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<ListPage>(`/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 (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("documents.linkPicker.title")}
description={t("documents.linkPicker.description")}
>
<div className="space-y-3">
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.linkPicker.searchPlaceholder")}
aria-label={t("documents.linkPicker.searchPlaceholder")}
className="pl-9"
/>
</div>
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("documents.linkPicker.empty")}
</p>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{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 (
<li key={doc.id}>
<button
type="button"
aria-pressed={linked}
disabled={toggle.isPending}
onClick={() =>
toggle.mutate({ documentId: doc.id, linked })
}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
linked && "border-primary/40 bg-primary/5",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
<span className="text-muted-foreground block text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
</span>
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
</button>
</li>
);
})}
</ul>
)}
</div>
</ResponsiveSheet>
);
}
+28 -1
View File
@@ -462,6 +462,33 @@ export function DocumentsView() {
setDetailOpen(true);
}, []);
// `?doc=<id>` 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<string | null>(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() {
<DocumentDetailSheet
documentId={detailId}
open={detailOpen}
onOpenChange={setDetailOpen}
onOpenChange={handleDetailOpenChange}
/>
{selectedIds.size > 0 ? (
@@ -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<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
if (!moduleEnabled) return null;
if (list.isError) {
return (
<QueryErrorCard
title={t("documents.episodeCard.loadError")}
onRetry={() => 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 (
<>
<Card data-slot="episode-documents-card">
<CardContent className="space-y-4">
<TileHeader
icon={FolderOpen}
title={t("documents.episodeCard.title")}
right={
documents.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{documents.length}
{hasMore ? "+" : ""}
</span>
) : undefined
}
/>
{list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.episodeCard.emptyLine")}
</p>
) : (
<ul className="space-y-1.5">
{documents.map((doc) => {
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<ListRow asChild>
<Link
href={`${vaultHref}&doc=${encodeURIComponent(doc.id)}`}
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{title}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
</span>
</Link>
</ListRow>
</li>
);
})}
</ul>
)}
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Link2 className="size-4" aria-hidden />
{t("documents.episodeCard.link")}
</Button>
<Button asChild variant="outline" size="sm">
<Link href={vaultHref}>
<Upload className="size-4" aria-hidden />
{t("documents.episodeCard.upload")}
</Link>
</Button>
{hasMore ? (
<Button
asChild
variant="ghost"
size="sm"
className="text-muted-foreground ml-auto"
>
<Link href={vaultHref}>
{t("documents.episodeCard.viewAll")}
</Link>
</Button>
) : null}
</div>
</CardContent>
</Card>
<DocumentLinkPicker
episodeId={episodeId}
open={pickerOpen}
onOpenChange={setPickerOpen}
/>
</>
);
}
@@ -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 }) {
<IllnessCorrelationCard episodeId={episodeId} />
)}
{/* 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 ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
{episode ? (
<>
<LogDaySheet
+14
View File
@@ -38,6 +38,20 @@ export const documentKeys = {
inboundDocumentList: (filters: DocumentVaultFilters) =>
["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 /