"use client"; /** * The Dokumente vault — one wide, calm, filing-cabinet-fast surface. * * Filter state lives in the URL (`?q&kind&episode&encounter&year`) so every view is * shareable, back-button-safe, and deep-linkable from the illness page and * labs. Search debounces 200 ms and `/` focuses it. The timeline below is * virtualized; uploads appear optimistically above it in < 100 ms. * * Born-gated on the resolved `modules.inboundDocuments` flag from * `GET /api/auth/me` (per-user opt-in AND the operator availability layer). * An unauthenticated visitor bounces to login; an account without the * module bounces home. Every `/api/documents/inbound/*` route re-enforces * the gate server-side — this is a UX redirect, not the security boundary. */ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query"; import { FolderOpen, Loader2, SearchX, Upload, UploadCloud, Wrench, } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { bodySiteText, encounterKindText, } from "@/components/encounters/encounter-labels"; import { useEncounters } from "@/hooks/use-encounters"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { EncounterKind } from "@/generated/prisma/client"; import { type CoachCloseIntent, useCoachLaunch, } from "@/lib/insights/coach-launch-context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { useDocumentsLayout } from "@/lib/queries/use-documents-layout"; import { DOCUMENT_BULK_MAX_IDS, type DocumentBulkAction, type DocumentBulkResultDto, type DocumentUsageDto, type InboundDocumentDetailDto, type InboundDocumentDto, type InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; import { DocumentBulkBar } from "./document-bulk-bar"; import { DocumentDetailSheet } from "./document-detail-sheet"; import { DocumentShareSheet } from "./document-share-sheet"; import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar"; import { DocumentTimeline } from "./document-timeline"; import { UploadZone } from "./upload-zone"; import { ImportFromSourceButton } from "./sources/import-from-source-button"; import { useReindexAll } from "./use-content-index"; import { useDocumentUpload } from "./use-document-upload"; import { usePageFileDrop } from "./use-page-file-drop"; import { buildProcedureChoices, buildVaultListApiSearch, countActiveFilters, documentDateKey, expandRangeSelection, hasProcessingDocument, parseVaultSearchParams, type ProcedureFilterChoice, resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, vaultFiltersToSearch, } from "./vault-utils"; interface ListPage { documents: InboundDocumentDto[]; nextCursor: string | null; } // Strict end-of-input: `$` also accepts a trailing newline in JavaScript. /** * How many visits the bulk bar offers. A menu is a picker, not a history: past * the first screenful a person scrolls rather than reads, and filing against a * visit older than this belongs on the visit's own sheet. */ const BULK_VISIT_OPTIONS = 12; const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/; const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection"; export function documentSelectionHistoryState( documentId: string, ): Record { // Pass only application state here. Next's patched pushState copies its // private router fields onto this object; copying history.state ourselves // would include `__NA` and make that patch treat this as an internal write, // skipping useSearchParams synchronization. return { [DOCUMENT_SELECTION_HISTORY_KEY]: documentId }; } export function documentSelectionHref( pathname: string, currentSearch: string, documentId: string, ): string | null { if (!DOCUMENT_QUERY_ID_PATTERN.test(documentId)) return null; const next = new URLSearchParams(currentSearch); next.set("doc", documentId); const search = next.toString(); return search ? `${pathname}?${search}` : pathname; } export function withoutDocumentSelectionHref( pathname: string, currentSearch: string, ): string { const next = new URLSearchParams(currentSearch); next.delete("doc"); const search = next.toString(); return search ? `${pathname}?${search}` : pathname; } /** * How long to wait before checking that `history.back()` actually happened. * * Long enough for a traversal to commit on a loaded machine, short enough that * a reader who closed the sheet does not see the selection linger in the * address bar. The window is bounded on the other side too: the effect that * arms this cancels it as soon as the sheet re-opens. */ export const CLOSE_TRAVERSAL_GRACE_MS = 250; /** * The selection this close was meant to consume is still in the address bar. * * Matched on the id rather than on the presence of `doc`, so a traversal that * landed somewhere carrying a DIFFERENT selection is not mistaken for a * dropped one and stripped. */ export function documentSelectionSurvivedClose( search: string, documentId: string, ): boolean { return new URLSearchParams(search).get("doc") === documentId; } export function closeDocumentSelectionHistoryEntry( history: { readonly state: unknown; back: () => void; replaceState: ( data: unknown, unused: string, url?: string | URL | null, ) => void; }, pathname: string, currentSearch: string, documentId: string, ): void { const ownsCurrentEntry = history.state !== null && typeof history.state === "object" && (history.state as Record)[ DOCUMENT_SELECTION_HISTORY_KEY ] === documentId; if (ownsCurrentEntry) { // Card selection owns the current pushed entry. Returning to its base // consumes it, so the user's next Back reaches the prior distinct page. history.back(); return; } // A deep link did not create a disposable entry on this page. Keep the user // here and remove only the selection from the current URL. history.replaceState( null, "", withoutDocumentSelectionHref(pathname, currentSearch), ); } /** * The selection is being KEPT on purpose, as the way back. * * Maximizing the Coach drawer hands the conversation to `/coach` and carries * `?doc=` with it, so browser-Back can reconstruct the sheet. That is the one * close-shaped transition that must leave the parameter exactly where it is. * * Named, and used by both the close and the check that follows it, because * those two disagreeing is a bug with no symptom until it has one: the check * would strip the return URL a few hundred milliseconds after the close * deliberately preserved it, and whether it won that race was a question about * how fast the machine navigated. */ export function coachHandoffRetainsSelection( documentId: string, handedOffDocumentId: string | null, closeIntent: CoachCloseIntent | null | undefined, ): boolean { return handedOffDocumentId === documentId && closeIntent === "navigate"; } export function closeDocumentSelectionAfterCoachHandoff( history: Parameters[0], pathname: string, currentSearch: string, documentId: string, handedOffDocumentId: string | null, closeIntent: CoachCloseIntent | null | undefined, ): string | null { if ( coachHandoffRetainsSelection(documentId, handedOffDocumentId, closeIntent) ) { return handedOffDocumentId; } closeDocumentSelectionHistoryEntry( history, pathname, currentSearch, documentId, ); return handedOffDocumentId === documentId ? null : handedOffDocumentId; } export function DocumentsView() { const { t } = useTranslations(); const format = useFormatters(); const { user, isLoading: authLoading, isAuthenticated } = useAuth(); const timezone = user?.timezone || DEFAULT_TIMEZONE; // v1.36.x — uploading a document, filing it, sharing it and asking the AI // about it are none of them delegated verbs. Inside somebody else's record // the vault reads and nothing more: no upload path, no selection, no bulk // bar, no corpus backfill. `canManageDomain("documents")` says so from the // server's table, and answers false under every grant today. const { canManageDomain, inSharedRecord, sections } = useRecordCapabilities(); const canManageDocuments = canManageDomain("documents"); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); const coachLaunch = useCoachLaunch(); const moduleEnabled = user?.modules?.inboundDocuments === true; // UX redirect only — the API routes enforce the gate server-side. useEffect(() => { if (authLoading) return; if (!isAuthenticated) { router.push("/auth/login"); } else if (!moduleEnabled) { router.push("/"); } }, [authLoading, isAuthenticated, moduleEnabled, router]); // ── URL-owned filter state ──────────────────────────────────────────── // The parse/serialise round trip stays in `vault-utils.ts`; the router // plumbing lives in the shared hook (which the measurements and mood // lists reuse — this surface is the pattern's origin). const { filters, applyFilters } = useUrlFilterSync({ parse: parseVaultSearchParams, serialise: vaultFiltersToSearch, }); // Search draft debounces into the URL (200 ms); an external URL change // (back button, deep link) re-seeds the draft — render-phase derived-state // adjustment, not an effect. const [searchDraft, setSearchDraft] = useState(filters.q ?? ""); const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? ""); const searchInputRef = useRef(null); if ((filters.q ?? "") !== lastUrlQ) { setLastUrlQ(filters.q ?? ""); setSearchDraft(filters.q ?? ""); } useEffect(() => { const handle = setTimeout(() => { const trimmed = searchDraft.trim(); if (trimmed === (filters.q ?? "")) return; applyFilters( { ...filters, q: trimmed === "" ? undefined : trimmed }, "replace", ); }, 200); return () => clearTimeout(handle); }, [searchDraft, filters, applyFilters]); // `/` focuses the search from anywhere on the page. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target as HTMLElement | null; if ( target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable) ) { return; } event.preventDefault(); searchInputRef.current?.focus(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); const toggleKind = (kind: InboundDocumentKindValue) => { const current = new Set(filters.kinds ?? []); if (current.has(kind)) { current.delete(kind); } else { current.add(kind); } applyFilters( { ...filters, kinds: current.size > 0 ? [...current].sort() : undefined, }, "push", ); }; const toggleEpisode = (episodeId: string) => { applyFilters( { ...filters, episodeId: filters.episodeId === episodeId ? undefined : episodeId, }, "push", ); }; const toggleEncounter = (encounterId: string) => { applyFilters( { ...filters, encounterId: filters.encounterId === encounterId ? undefined : encounterId, }, "push", ); }; const toggleYear = (year: number) => { applyFilters( { ...filters, year: filters.year === year ? undefined : year }, "push", ); }; const clearFilters = () => { setSearchDraft(""); applyFilters({}, "push"); }; // ── Data ────────────────────────────────────────────────────────────── const usage = useQuery({ queryKey: queryKeys.inboundDocumentUsage(), enabled: moduleEnabled, queryFn: () => apiGet("/api/documents/inbound/usage"), }); const list = useInfiniteQuery({ queryKey: queryKeys.inboundDocumentList(filters), enabled: moduleEnabled, queryFn: ({ pageParam }) => apiGet( `/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`, ), initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor, // v1.29.x — the auto-index (+ optional AI summary) job that lands right // after upload is fire-and-forget server-side; nothing pushes its // completion to the client. Poll while any loaded document is still // inside its recent-upload processing window (see `hasProcessingDocument` // — bounded, so an old/permanently-unindexed document never keeps this // polling forever) so the card's "Processing…" chip clears on its own // once the index lands, without a manual refresh. refetchInterval: (query) => { const pages = query.state.data?.pages ?? []; const docs = pages.flatMap((p) => p.documents); return hasProcessingDocument(docs) ? 4000 : false; }, }); const documents = useMemo( () => list.data?.pages.flatMap((p) => p.documents) ?? [], [list.data], ); // The reader's presentation (cards / list, stacked / flowing months), // persisted per user and changed behind the wrench. const { layout, isLayoutLoading } = useDocumentsLayout(moduleEnabled); // v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/ // measurements/medications. The vault is one of the sharpest PWA-resume- // staleness surfaces named in the audit: a document indexed server-side // while the tab was backgrounded (auto-index / AI summary) otherwise // stays invisible until the bounded processing poll above happens to // catch it or the user navigates away and back. const refreshDocuments = useCallback( () => Promise.all([list.refetch(), usage.refetch()]), [list, usage], ); const pull = usePullToRefresh({ onRefresh: refreshDocuments }); const upload = useDocumentUpload( usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined, ); const uploadInputRef = useRef(null); // One intake path for every source (picker, zone drop, page-wide drop, // clipboard paste): pre-links to the active episode filter and feeds the // sr-only live region — the drop overlay itself is decorative. const [uploadAnnouncement, setUploadAnnouncement] = useState(""); const enqueueRef = useRef(upload.enqueue); useEffect(() => { enqueueRef.current = upload.enqueue; }, [upload.enqueue]); const episodeIdFilter = filters.episodeId; const encounterIdFilter = filters.encounterId; const enqueueFiles = useCallback( (files: File[]) => { if (files.length === 0) return; enqueueRef.current(files, { episodeId: episodeIdFilter, encounterId: encounterIdFilter, }); setUploadAnnouncement( t("documents.upload.queuedAnnouncement", { count: files.length }), ); }, [episodeIdFilter, encounterIdFilter, t], ); const { dropActive } = usePageFileDrop( canManageDocuments ? enqueueFiles : undefined, ); const episodes = useIllnessEpisodes( true, !inSharedRecord || sections === null || sections.includes("illness"), ); // Content-search coverage: fire the corpus backfill when indexing is // available and some documents are not yet searchable. The list search // already unions content matches server-side — this only surfaces the hint // and the "index all" affordance from the usage gauge. const reindexAll = useReindexAll(); const contentIndex = usage.data?.contentIndex; const canIndexContent = contentIndex?.enabled ?? false; const showIndexAll = canManageDocuments && canIndexContent && contentIndex !== undefined && contentIndex.totalCount > 0 && contentIndex.indexedCount < contentIndex.totalCount; const handleIndexAll = useCallback(() => { reindexAll.mutate(undefined, { onSuccess: (result) => toast.success( result.enqueued > 0 ? t("documents.contentIndex.indexAllQueued", { count: result.enqueued, }) : t("documents.contentIndex.indexAllNothing"), ), onError: () => toast.error(t("documents.contentIndex.indexAllError")), }); }, [reindexAll, t]); // Condition chips: every episode carrying at least one live document // link, served by the usage endpoint (NOT derived from the loaded corpus // — an old linked document pages deep must still surface its chip), plus // the actively filtered episode (a deep link must always show its own // chip, even when its last link was just removed). const conditionChips = useMemo(() => { const byId = new Map(); for (const link of usage.data?.linkedEpisodes ?? []) { byId.set(link.episodeId, link.name); } if (filters.episodeId && !byId.has(filters.episodeId)) { const episode = episodes.data?.find((e) => e.id === filters.episodeId); byId.set(filters.episodeId, episode?.label ?? filters.episodeId); } return [...byId.entries()] .map(([episodeId, name]) => ({ episodeId, name })) .sort((a, b) => a.name.localeCompare(b.name)); }, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]); // The visits the bulk bar can file a selection against: the account's own, // read through the same list the checkups page shows. Labelled here rather // than in the bar so the label uses the reader's locale for the kind and the // reader's timezone for the date — both presentation, both this side's job. const visits = useEncounters( !inSharedRecord || sections === null || sections.includes("profile"), ); const bulkEncounterOptions = useMemo( () => [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])] .slice(0, BULK_VISIT_OPTIONS) .map((visit) => ({ id: visit.id, label: `${ visit.practitioner?.name ?? encounterKindText(t, visit.kind as EncounterKind) }, ${format.date(visit.occurredAt)}`, })), [visits.data, t, format], ); // Procedure choices: every procedure carrying at least one live document // link, served by the usage endpoint for the same reason as the condition // chips, plus the actively filtered visit when it is a procedure (see // `buildProcedureChoices`). const procedureChips = useMemo(() => { const activeVisit = filters.encounterId ? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find( (v) => v.id === filters.encounterId && v.kind === "PROCEDURE", ) : undefined; return buildProcedureChoices( usage.data?.linkedProcedures ?? [], activeVisit ? { encounterId: activeVisit.id, occurredAt: activeVisit.occurredAt, reason: activeVisit.reason, bodySite: activeVisit.bodySite, laterality: activeVisit.laterality, practitionerName: activeVisit.practitioner?.name ?? null, } : null, { kindName: encounterKindText(t, "PROCEDURE"), siteText: (site, laterality) => bodySiteText(t, site, laterality), formatDate: (iso) => format.date(iso), }, ); }, [ usage.data?.linkedProcedures, filters.encounterId, visits.data, t, format, ]); // Year segmenter: years present in the loaded corpus (+ the active year). const years = useMemo(() => { const set = new Set(); for (const doc of documents) { set.add(Number(documentDateKey(doc, timezone).slice(0, 4))); } if (filters.year !== undefined) set.add(filters.year); return [...set].sort((a, b) => b - a); }, [documents, filters.year, timezone]); // ── Selection + bulk actions ────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>( new Set(), ); // Anchor for shift-click ranges: the last plainly-toggled id. const rangeAnchorRef = useRef(null); const toggleSelected = useCallback( (id: string, range?: boolean) => { setSelectedIds((prev) => { if (range) { return expandRangeSelection( documents.map((d) => d.id), prev, rangeAnchorRef.current, id, ); } const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); if (!range) rangeAnchorRef.current = id; }, [documents], ); const clearSelection = useCallback(() => { setSelectedIds(new Set()); rangeAnchorRef.current = null; }, []); // Escape clears the selection — unless a dialog/popover owns the key. useEffect(() => { if (selectedIds.size === 0) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; const target = event.target as HTMLElement | null; if (target?.closest('[role="dialog"],[role="menu"]')) return; clearSelection(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [selectedIds.size, clearSelection]); /** * One bulk POST per ≤100-id chunk (the endpoint's cap); per-id results * are merged so a partial failure in any chunk surfaces once. The * endpoint is no-op-success on already-in-state rows, so an undo toast * firing twice is safe by contract. */ const bulk = useMutation({ mutationFn: async (input: { ids: string[]; action: DocumentBulkAction; kind?: InboundDocumentKindValue; episodeId?: string; }) => { const results: DocumentBulkResultDto[] = []; for (let i = 0; i < input.ids.length; i += DOCUMENT_BULK_MAX_IDS) { const chunk = input.ids.slice(i, i + DOCUMENT_BULK_MAX_IDS); const page = await apiPost<{ results: DocumentBulkResultDto[] }>( "/api/documents/inbound/bulk", { ids: chunk, action: input.action, ...(input.kind !== undefined && { kind: input.kind }), ...(input.episodeId !== undefined && { episodeId: input.episodeId, }), }, ); results.push(...page.results); } return results; }, onSettled: () => { void invalidateKeys(queryClient, [queryKeys.documents()]); }, }); const reportBulkOutcome = useCallback( (results: DocumentBulkResultDto[], successMessage: string) => { const failed = results.filter((r) => !r.ok).length; if (failed > 0) { toast.error( t("documents.bulk.partialFailure", { failed, total: results.length, }), ); } else { toast.success(successMessage); } }, [t], ); const runBulk = useCallback( ( action: Exclude, extra: { kind?: InboundDocumentKindValue; episodeId?: string; encounterId?: string; }, ) => { const ids = [...selectedIds]; bulk.mutate( { ids, action, ...extra }, { onSuccess: (results) => { reportBulkOutcome( results, t("documents.bulk.updated", { count: results.filter((r) => r.ok).length, }), ); clearSelection(); }, onError: () => toast.error(t("documents.bulk.failed")), }, ); }, [selectedIds, bulk, reportBulkOutcome, clearSelection, t], ); const restoreBulk = useCallback( (ids: string[]) => { bulk.mutate( { ids, action: "restore" }, { onSuccess: (results) => reportBulkOutcome( results, t("documents.bulk.restored", { count: results.filter((r) => r.ok).length, }), ), onError: () => toast.error(t("documents.toast.restoreFailed")), }, ); }, [bulk, reportBulkOutcome, t], ); const deleteBulk = useCallback( (ids: string[]) => { bulk.mutate( { ids, action: "delete" }, { onSuccess: (results) => { const okIds = results.filter((r) => r.ok).map((r) => r.id); const failed = results.length - okIds.length; if (failed > 0) { toast.error( t("documents.bulk.partialFailure", { failed, total: results.length, }), ); } if (okIds.length > 0) { // ONE aggregate undo for the whole batch (bulk restore). toast.success( t("documents.bulk.deleted", { count: okIds.length }), { action: { label: t("common.undo"), onClick: () => restoreBulk(okIds), }, }, ); } clearSelection(); }, onError: () => toast.error(t("documents.bulk.failed")), }, ); }, [bulk, clearSelection, restoreBulk, t], ); // ── Delete confirmation ─────────────────────────────────────────────── // Every way of deleting from the vault page — the bulk bar's Delete and a // card's Delete key — comes through here and asks first. Nothing on this // page calls `deleteBulk` directly. const [pendingDeleteIds, setPendingDeleteIds] = useState( null, ); const requestDelete = useCallback((ids: string[]) => { if (ids.length > 0) setPendingDeleteIds(ids); }, []); const confirmDelete = useCallback(() => { if (pendingDeleteIds) deleteBulk(pendingDeleteIds); setPendingDeleteIds(null); }, [pendingDeleteIds, deleteBulk]); // ── Bulk share ──────────────────────────────────────────────────────── // ONE documents-only link for the whole selection (the share model carries // up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded // corpus — a selected id is always a rendered row. Over the cap we surface a // hint and refuse rather than silently dropping docs from the link. const [bulkShareOpen, setBulkShareOpen] = useState(false); const [bulkShareDocs, setBulkShareDocs] = useState< { id: string; title: string }[] >([]); const openBulkShare = useCallback(() => { const resolved = resolveBulkShareDocuments( documents, selectedIds, t("documents.card.untitled"), ); if (resolved.overCap) { toast.error( t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }), ); return; } if (resolved.documents.length === 0) return; setBulkShareDocs(resolved.documents); setBulkShareOpen(true); }, [documents, selectedIds, t]); // ── Detail sheet ────────────────────────────────────────────────────── const [detailId, setDetailId] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const handedOffDocumentRef = useRef(null); const rawDocParam = searchParams.get("doc"); const docParam = rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam) ? rawDocParam : null; const [observedDocParam, setObservedDocParam] = useState(null); // The query is the navigation owner: direct links, browser back, and browser // forward all drive the same detail selection without remounting the route. // Wait for auth so a deep link cannot be consumed before the module gate is // known, and never interpolate an unvalidated id into the detail API path. if (!authLoading && moduleEnabled && rawDocParam !== observedDocParam) { setObservedDocParam(rawDocParam); setDetailId(docParam); setDetailOpen(docParam !== null); } const openDetail = useCallback( (id: string) => { const href = documentSelectionHref(pathname, searchParams.toString(), id); if (href === null) return; handedOffDocumentRef.current = null; setDetailId(id); setDetailOpen(true); window.history.pushState(documentSelectionHistoryState(id), "", href); }, [pathname, searchParams], ); const handleDetailOpenChange = useCallback((open: boolean) => { setDetailOpen(open); }, []); const coachOwnsDocument = docParam !== null && coachLaunch?.open === true && coachLaunch.documentId === docParam; useEffect(() => { if (authLoading || !moduleEnabled) return; if (docParam === null) { handedOffDocumentRef.current = null; return; } if (detailOpen) return; if (coachOwnsDocument) { // "Ask Coach" intentionally closes the sheet while the drawer owns the // same document. Keep the return URL intact so maximize → browser-back // can reconstruct the sheet from `?doc=`. handedOffDocumentRef.current = docParam; return; } // Read before the call below reassigns the ref: the same inputs the close // itself decides on. const retainedAsReturnUrl = coachHandoffRetainsSelection( docParam, handedOffDocumentRef.current, coachLaunch?.closeIntent, ); handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff( window.history, pathname, searchParams.toString(), docParam, handedOffDocumentRef.current, coachLaunch?.closeIntent, ); // Nothing was closed: the selection is the way back from `/coach` and is // meant to stay. Checking for it here would delete it. if (retainedAsReturnUrl) return; // Consuming the pushed entry is a REQUEST to the browser, not a guarantee. // // `history.back()` is queued against the session history, and a traversal // asked for while another is still settling can be dropped — closing the // sheet immediately after pressing the browser's Forward button is exactly // that shape. Nothing raises when it happens: the sheet is closed, React // is consistent, and the URL still names the document. A reload or a // shared link then re-opens the sheet the reader just closed. // // So the request is checked rather than trusted. If the selection is still // in the address bar shortly afterwards, it is removed the synchronous // way. The guard is the document id, so a traversal that landed on some // other selection is left alone, and the cleanup below cancels the check // the moment the sheet re-opens or the parameter changes — which is what // makes re-opening within the window safe. const verify = window.setTimeout(() => { if (!documentSelectionSurvivedClose(window.location.search, docParam)) { return; } window.history.replaceState( null, "", withoutDocumentSelectionHref(pathname, window.location.search), ); }, CLOSE_TRAVERSAL_GRACE_MS); return () => window.clearTimeout(verify); }, [ authLoading, coachOwnsDocument, coachLaunch?.closeIntent, detailOpen, docParam, moduleEnabled, pathname, 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( (id: string) => { void queryClient.prefetchQuery({ queryKey: queryKeys.inboundDocument(id), queryFn: () => apiGet(`/api/documents/inbound/${id}`), staleTime: 30_000, }); }, [queryClient], ); if (authLoading || !isAuthenticated || !moduleEnabled) { return (
{t("nav.loadingScreen")}
); } const activeCount = countActiveFilters(filters); const isFiltered = activeCount > 0; const showEmpty = list.isSuccess && documents.length === 0 && upload.items.length === 0; return (
{/* The wrench left of the primary action, as on the illness journal: it opens this vault's display settings. `/settings` is the caller's own, so it never shows inside somebody else's record. */} {!inSharedRecord ? ( ) : null} {canManageDocuments ? ( <> {/* Renders only with a connected Paperless-ngx or Papra. */} {/* An empty vault's empty state carries the upload. */} {showEmpty && !isFiltered ? null : ( )} ) : null}
) : null } /> {canManageDocuments && ( )} {inSharedRecord && !canManageDocuments && ( // Where the upload zone would be: the one empty space a person acting // for somebody else would otherwise search a control in.

{t("documents.ownerOnlyUpload")}

)} {list.isPending || isLayoutLoading ? ( layout.view === "list" ? (
{Array.from({ length: 6 }, (_, i) => ( ))}
) : (
{Array.from({ length: 8 }, (_, i) => (
))}
) ) : list.isError ? ( void list.refetch()} /> ) : showEmpty && !isFiltered ? ( } title={t("documents.empty.title")} description={t("documents.empty.description")} action={ canManageDocuments ? ( ) : undefined } /> ) : showEmpty && isFiltered ? ( } title={t("documents.empty.noMatchesTitle")} description={t("documents.empty.noMatchesDescription")} action={ } /> ) : ( void list.fetchNextPage()} selectedIds={selectedIds} onToggleSelected={canManageDocuments ? toggleSelected : undefined} onOpen={openDetail} onDelete={ canManageDocuments ? (id) => requestDelete([id]) : undefined } highlightId={upload.highlightId} onPrefetch={prefetchDetail} /> )} {canManageDocuments && selectedIds.size > 0 ? ( ({ id: e.id, label: e.label, }))} busy={bulk.isPending} onSetKind={(kind) => runBulk("setKind", { kind })} onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })} encounters={bulkEncounterOptions} onLinkEncounter={(encounterId) => runBulk("linkEncounter", { encounterId }) } onShare={openBulkShare} onRequestDelete={() => requestDelete([...selectedIds])} onClear={clearSelection} /> ) : null} { if (!open) setPendingDeleteIds(null); }} title={ pendingDeleteIds !== null && pendingDeleteIds.length > 1 ? t("documents.deleteConfirm.titleMany", { count: pendingDeleteIds.length, }) : t("documents.deleteConfirm.titleOne") } body={t("documents.deleteConfirm.body")} confirmLabel={t("documents.deleteConfirm.action")} onConfirm={confirmDelete} slot="documents-delete" destructive /> {/* Page-wide drop overlay — pure decoration (aria-hidden); the intake itself announces through the live region below. */} {dropActive ? (

{t("documents.dropOverlay.title")}

{t("documents.dropOverlay.hint")}

) : null}

{uploadAnnouncement}

); }