diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index c80956ae2..1b773d4bb 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -2,41 +2,20 @@ /** * "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. + * the episode's detail page. The shared `DocumentPickerSheet` lists and + * searches the vault; toggling a row 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. */ -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 { useMutation, useQueryClient } from "@tanstack/react-query"; 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 { 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"; -import { EmptyState } from "@/components/ui/empty-state"; - -interface ListPage { - documents: InboundDocumentDto[]; - nextCursor: string | null; -} +import type { DocumentBulkResultDto } from "@/lib/validations/inbound-documents"; +import { DocumentPickerSheet } from "./document-picker-sheet"; export function DocumentLinkPicker({ episodeId, @@ -48,39 +27,8 @@ export function DocumentLinkPicker({ 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[] }>( @@ -103,99 +51,21 @@ export function DocumentLinkPicker({ }, }); - 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 ? ( - - ) : ( -
    - {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 ( -
  • - -
  • - ); - })} -
- )} -
-
+ searchPlaceholder={t("documents.linkPicker.searchPlaceholder")} + emptyLabel={t("documents.linkPicker.empty")} + isPicked={(doc) => + doc.conditionLinks.some((l) => l.episodeId === episodeId) + } + isDisabled={() => toggle.isPending} + onToggle={(doc, _title, linked) => + toggle.mutate({ documentId: doc.id, linked }) + } + /> ); } diff --git a/src/components/documents/document-picker-sheet.tsx b/src/components/documents/document-picker-sheet.tsx new file mode 100644 index 000000000..6794e3650 --- /dev/null +++ b/src/components/documents/document-picker-sheet.tsx @@ -0,0 +1,194 @@ +"use client"; + +/** + * The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on + * phones) with a debounced search over the person's own vault — the same + * recall path as the vault page, one page (50) of matches, the search input + * as the way to an older document — and one toggle row per document. + * + * Two pickers used to carry this whole body as copies: linking documents to + * an illness episode (each toggle writes at once) and choosing documents for + * a share link (the choice is local state, capped, posted with the link). + * What a toggle means stays with each caller; the list, the search, the rows + * and their states live here once. + */ +import { useQuery } from "@tanstack/react-query"; +import { Check, Search } from "lucide-react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import { useDocumentDate } from "@/components/documents/use-document-date"; +import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Input } from "@/components/ui/input"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +interface ListPage { + documents: InboundDocumentDto[]; + nextCursor: string | null; +} + +export function DocumentPickerSheet({ + open, + onOpenChange, + title, + description, + searchPlaceholder, + emptyLabel, + isPicked, + onToggle, + isDisabled, + notice, + footer, + listTestId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + searchPlaceholder: string; + emptyLabel: string; + isPicked: (doc: InboundDocumentDto) => boolean; + /** `title` is the row's display title (title, filename or "untitled"). */ + onToggle: (doc: InboundDocumentDto, title: string, picked: boolean) => void; + isDisabled?: (doc: InboundDocumentDto, picked: boolean) => boolean; + /** A status line above the list (the share cap). */ + notice?: ReactNode; + footer?: ReactNode; + listTestId?: string; +}) { + const { t } = useTranslations(); + const documentDate = useDocumentDate(); + + 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 documents = list.data?.documents ?? []; + + return ( + +
+
+ + onSearchChange(e.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="pl-9" + /> +
+ + {notice} + + {list.isPending && open ? ( +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+ ) : list.isError ? ( + list.refetch()} + /> + ) : documents.length === 0 ? ( + + ) : ( +
    + {documents.map((doc) => { + const picked = isPicked(doc); + const rowTitle = + doc.title ?? doc.filename ?? t("documents.card.untitled"); + const Icon = DOCUMENT_KIND_ICONS[doc.kind]; + const disabled = isDisabled?.(doc, picked) ?? false; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx index 4778a708f..65dfc1f05 100644 --- a/src/components/settings/share-document-picker.tsx +++ b/src/components/settings/share-document-picker.tsx @@ -9,27 +9,13 @@ * is bounded at `max` (mirrors `SHARE_LINK_MAX_DOCUMENTS`): once the cap is * reached, unselected rows disable so the client never posts an oversized set. * - * Reuses the vault list endpoint + the `inboundDocumentPicker` query key from - * the central factory; reads unwrap `(await res.json()).data` via `apiGet`. - * No write happens here — attaching is the create mutation's job. + * The list, search and rows are the shared `DocumentPickerSheet`. No write + * happens here — attaching is the create mutation's job. */ -import { useDocumentDate } from "@/components/documents/use-document-date"; -import { useQuery } from "@tanstack/react-query"; -import { Check, Search } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; - +import { DocumentPickerSheet } from "@/components/documents/document-picker-sheet"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; -import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; -import { EmptyState } from "@/components/ui/empty-state"; /** A picked document, carried by id + a display title (for the owner chips). */ export interface PickedDocument { @@ -37,11 +23,6 @@ export interface PickedDocument { title: string; } -interface ListPage { - documents: InboundDocumentDto[]; - nextCursor: string | null; -} - export function ShareDocumentPicker({ open, onOpenChange, @@ -56,38 +37,6 @@ export function ShareDocumentPicker({ max: number; }) { const { t } = useTranslations(); - const documentDate = useDocumentDate(); - - 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 selectedIds = new Set(selected.map((s) => s.id)); const atCap = selected.length >= max; @@ -100,14 +49,28 @@ export function ShareDocumentPicker({ onSelectedChange([...selected, { id: doc.id, title }]); } - const documents = list.data?.documents ?? []; - return ( - selectedIds.has(doc.id)} + isDisabled={(_doc, picked) => !picked && atCap} + onToggle={(doc, title) => toggle(doc, title)} + notice={ + atCap ? ( +

+ {t("settings.sharing.pickerMaxReached", { max })} +

+ ) : null + } footer={
@@ -121,101 +84,6 @@ export function ShareDocumentPicker({
} - > -
-
- - onSearchChange(e.target.value)} - placeholder={t("settings.sharing.pickerSearch")} - aria-label={t("settings.sharing.pickerSearch")} - className="pl-9" - /> -
- - {atCap ? ( -

- {t("settings.sharing.pickerMaxReached", { max })} -

- ) : null} - - {list.isPending && open ? ( -
- {Array.from({ length: 4 }, (_, i) => ( - - ))} -
- ) : list.isError ? ( - list.refetch()} - /> - ) : documents.length === 0 ? ( - - ) : ( -
    - {documents.map((doc) => { - const picked = selectedIds.has(doc.id); - const title = - doc.title ?? doc.filename ?? t("documents.card.untitled"); - const Icon = DOCUMENT_KIND_ICONS[doc.kind]; - const disabled = !picked && atCap; - return ( -
  • - -
  • - ); - })} -
- )} -
-
+ /> ); }