mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
8f442c3d6c
commit
ed2ac8b61d
12 files changed
+608
-1
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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&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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 /
|
||||
|
||||
Reference in new issue
Block a user