"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 { useDocumentDate } from "@/components/documents/use-document-date"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { 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"; 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 documentDate = useDocumentDate(); 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)) { showErrorToast(t("documents.bulk.failed")); } }, onError: (failure) => showErrorToast(t("documents.bulk.failed"), { cause: failure }), 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 ? ( list.refetch()} /> ) : 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 (
  • ); })}
)}
); }