From 3eb5aa93640918b921f4a009b8f19261bb9d4698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:35:46 +0200 Subject: [PATCH] refactor(documents): split the vault page into filters, library, selection and detail DocumentsView carried 970 lines of state before its first element. The state moves to components/documents/vault/: - selection-history: the `?doc=` history entry helpers (pure, tested) - use-vault-filters: URL filters, the debounced search and the `/` key - use-vault-library: usage, the paged list and its processing poll, layout, uploads and their intake paths, the corpus-index affordance - use-vault-filter-choices: conditions, procedures, visits, years - use-vault-selection and use-vault-bulk: the selection with ranges and Escape, the confirmed delete, re-filing, linking and the bulk share - use-vault-detail: the detail sheet driven by `?doc=`, its close and the check that the close took - vault-page-actions, vault-drop-overlay The page composes them in about 300 lines; markup and behaviour are unchanged, and the guards follow the code they watch. --- .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 5 +- .../client-poll-inventory-guard.test.ts | 2 +- .../destructive-control-guard.test.ts | 3 +- .../success-affordance-guard.test.ts | 7 +- src/__tests__/url-state-writes-guard.test.ts | 2 +- .../documents-delete-confirm-guard.test.ts | 6 +- .../documents-view-navigation.test.ts | 2 +- src/components/documents/documents-view.tsx | 1049 ++--------------- .../documents/vault/selection-history.ts | 148 +++ .../documents/vault/use-vault-bulk.ts | 176 +++ .../documents/vault/use-vault-detail.ts | 180 +++ .../vault/use-vault-filter-choices.ts | 138 +++ .../documents/vault/use-vault-filters.ts | 134 +++ .../documents/vault/use-vault-library.ts | 205 ++++ .../documents/vault/use-vault-selection.ts | 134 +++ .../documents/vault/vault-drop-overlay.tsx | 30 + .../documents/vault/vault-page-actions.tsx | 70 ++ 19 files changed, 1328 insertions(+), 972 deletions(-) create mode 100644 src/components/documents/vault/selection-history.ts create mode 100644 src/components/documents/vault/use-vault-bulk.ts create mode 100644 src/components/documents/vault/use-vault-detail.ts create mode 100644 src/components/documents/vault/use-vault-filter-choices.ts create mode 100644 src/components/documents/vault/use-vault-filters.ts create mode 100644 src/components/documents/vault/use-vault-library.ts create mode 100644 src/components/documents/vault/use-vault-selection.ts create mode 100644 src/components/documents/vault/vault-drop-overlay.tsx create mode 100644 src/components/documents/vault/vault-page-actions.tsx diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 8f2f24790..2bee410d8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 119, - "total": 137765, + "count": 118, + "total": 136542, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -16,7 +16,6 @@ "components/charts/mood-chart.tsx": 1166, "components/cycle/log-day-sheet.tsx": 1639, "components/documents/document-detail-sheet.tsx": 1304, - "components/documents/documents-view.tsx": 1223, "components/insights/coach-panel/chat-bubble.tsx": 888, "components/insights/coach-panel/coach-conversation.tsx": 1286, "components/insights/coach-panel/use-coach.ts": 1486, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 19a5f68ca..93dcdaa32 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 405, - "total": 140971, + "total": 140305, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -127,7 +127,7 @@ "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, "components/documents/document-record-links.tsx :: DocumentRecordLinks": 202, "components/documents/document-timeline.tsx :: DocumentTimeline": 314, - "components/documents/documents-view.tsx :: DocumentsView": 969, + "components/documents/documents-view.tsx :: DocumentsView": 303, "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 565, "components/illness/log-day-sheet.tsx :: LogDaySheet": 265, "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 2705ce464..e546e423d 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2951, - "total": 10297, + "count": 2950, + "total": 10294, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -836,7 +836,6 @@ "components/documents/document-detail-sheet.tsx": 6, "components/documents/document-facts-review.tsx": 2, "components/documents/document-summary-block.tsx": 2, - "components/documents/documents-view.tsx": 3, "components/documents/use-content-index.ts": 1, "components/documents/use-document-assist.ts": 1, "components/documents/use-document-facts.ts": 1, diff --git a/src/__tests__/client-poll-inventory-guard.test.ts b/src/__tests__/client-poll-inventory-guard.test.ts index c3e14afa2..aa9df73ba 100644 --- a/src/__tests__/client-poll-inventory-guard.test.ts +++ b/src/__tests__/client-poll-inventory-guard.test.ts @@ -89,7 +89,7 @@ const INVENTORY: Readonly> = { shape: "until-done", cadence: "2 s until no restore is active", }, - "components/documents/documents-view.tsx": { + "components/documents/vault/use-vault-library.ts": { sites: 1, shape: "until-done", cadence: diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index d7ad190f2..023c98402 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -606,9 +606,10 @@ const REGISTRY: DestructiveEntry[] = [ ], }, { - file: "components/documents/documents-view.tsx", + file: "components/documents/vault/use-vault-bulk.ts", destroys: "every selected document, or one document from a focused card", triggers: [ + "components/documents/documents-view.tsx", "components/documents/document-bulk-bar.tsx", "components/documents/document-card.tsx", ], diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index f07694e03..335674dfe 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -177,7 +177,12 @@ const PINNED_AFFORDANCES: Record< "src/components/dashboard/range-display.tsx": { "text-success": 2 }, "src/components/documents/document-card.tsx": { "text-success": 1 }, "src/components/documents/document-detail-sheet.tsx": { "toast.success": 2 }, - "src/components/documents/documents-view.tsx": { "toast.success": 3 }, + "src/components/documents/vault/use-vault-library.ts": { + "toast.success": 1, + }, + "src/components/documents/vault/use-vault-bulk.ts": { + "toast.success": 2, + }, // The document picker's per-row result: tinted only for the `imported` / // `duplicate` outcome the import route returned for that one document, i.e. // a document the vault now holds. The run's headline goes through diff --git a/src/__tests__/url-state-writes-guard.test.ts b/src/__tests__/url-state-writes-guard.test.ts index 8c0740162..bb4a087f9 100644 --- a/src/__tests__/url-state-writes-guard.test.ts +++ b/src/__tests__/url-state-writes-guard.test.ts @@ -24,7 +24,7 @@ const ALLOWED: Record = { "lib/url-state.ts": "the helper itself", "components/clinician/share-unlock-gate.tsx": "scrubs a passphrase from the fragment synchronously, before the unlock request leaves; no query state", - "components/documents/documents-view.tsx": + "components/documents/vault/selection-history.ts": "closeDocumentSelectionHistoryEntry takes a history-shaped argument for its tests; the page passes `urlStateHistory`", }; diff --git a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts index abfde0ac0..8bc66631d 100644 --- a/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts +++ b/src/components/documents/__tests__/documents-delete-confirm-guard.test.ts @@ -17,7 +17,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const dir = join(process.cwd(), "src/components/documents"); -const view = readFileSync(join(dir, "documents-view.tsx"), "utf8"); +// The page and the selection hook it takes its delete path from. +const view = [ + readFileSync(join(dir, "documents-view.tsx"), "utf8"), + readFileSync(join(dir, "vault/use-vault-selection.ts"), "utf8"), +].join("\n"); const sheet = readFileSync(join(dir, "document-detail-sheet.tsx"), "utf8"); const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8"); diff --git a/src/components/documents/__tests__/documents-view-navigation.test.ts b/src/components/documents/__tests__/documents-view-navigation.test.ts index ab96a78a8..075d8c08e 100644 --- a/src/components/documents/__tests__/documents-view-navigation.test.ts +++ b/src/components/documents/__tests__/documents-view-navigation.test.ts @@ -8,7 +8,7 @@ import { documentSelectionHref, documentSelectionSurvivedClose, withoutDocumentSelectionHref, -} from "../documents-view"; +} from "../vault/selection-history"; class MemoryHistory { private entries: { href: string; state: unknown }[]; diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 12b6614ee..da5c67a9d 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -1,5 +1,7 @@ "use client"; +"use client"; + /** * The Dokumente vault — one wide, calm, filing-cabinet-fast surface. * @@ -14,260 +16,54 @@ * 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 } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { showErrorToast } from "@/lib/errors/error-toast"; +import { useEffect } from "react"; +import { FolderOpen, Loader2, SearchX, Upload } from "lucide-react"; -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 { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useTranslations } from "@/lib/i18n/context"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; -import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; -import { - currentUrlSearch, - urlStateHistory, - useUrlSearch, - writeUrlState, -} from "@/lib/url-state"; -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 type { - DocumentBulkAction, - DocumentBulkResultDto, - DocumentUsageDto, - InboundDocumentDetailDto, - InboundDocumentDto, - InboundDocumentKindValue, -} from "@/lib/validations/inbound-documents"; -import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants"; + import { DocumentBulkBar } from "./document-bulk-bar"; import { DocumentDetailSheet } from "./document-detail-sheet"; +import { DocumentFilterBar } from "./document-filter-bar"; 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; -} +import { countActiveFilters } from "./vault-utils"; +import { useVaultDetail } from "./vault/use-vault-detail"; +import { useVaultFilters } from "./vault/use-vault-filters"; +import { useVaultLibrary } from "./vault/use-vault-library"; +import { useVaultSelection } from "./vault/use-vault-selection"; +import { VaultDropOverlay } from "./vault/vault-drop-overlay"; +import { VaultPageActions } from "./vault/vault-page-actions"; /** - * 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. + * The vault page. Its state lives in `./vault/`: the URL filters, the + * library (list, usage, uploads, filter choices), the selection and its bulk + * actions, and the `?doc=` detail sheet; this component lays them out. */ -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. + // 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 queryClient = useQueryClient(); - const coachLaunch = useCoachLaunch(); const moduleEnabled = user?.modules?.inboundDocuments === true; @@ -281,677 +77,67 @@ export function DocumentsView() { } }, [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; - // Live filters, not the rendered ones a "Clear filters" just removed. - applyFilters( - (current) => - trimmed === (current.q ?? "") - ? current - : { ...current, 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); - }, []); - - // Every facet toggles against the live filters (`applyFilters` updater). - const toggleKind = (kind: InboundDocumentKindValue) => { - applyFilters((current) => { - const kinds = new Set(current.kinds ?? []); - if (kinds.has(kind)) kinds.delete(kind); - else kinds.add(kind); - return { - ...current, - kinds: kinds.size > 0 ? [...kinds].sort() : undefined, - }; - }, "push"); - }; - - const toggleEpisode = (episodeId: string) => { - applyFilters( - (current) => ({ - ...current, - episodeId: current.episodeId === episodeId ? undefined : episodeId, - }), - "push", - ); - }; - - const toggleEncounter = (encounterId: string) => { - applyFilters( - (current) => ({ - ...current, - encounterId: - current.encounterId === encounterId ? undefined : encounterId, - }), - "push", - ); - }; - - const toggleYear = (year: number) => { - applyFilters( - (current) => ({ - ...current, - year: current.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 — 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: (failure) => - showErrorToast(t("documents.contentIndex.indexAllError"), { - cause: failure, - }), - }); - }, [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) { - showErrorToast( - 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: (failure) => - showErrorToast(t("documents.bulk.failed"), { cause: failure }), - }, - ); - }, - [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: (failure) => - showErrorToast(t("documents.toast.restoreFailed"), { - cause: failure, - }), - }, - ); - }, - [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) { - showErrorToast( - 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: (failure) => - showErrorToast(t("documents.bulk.failed"), { cause: failure }), - }, - ); - }, - [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) { - showErrorToast( - 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); - - // `?doc=` read live (`@/lib/url-state`): Next 16.4 adopts a pushState a - // render after the write, and a Back pressed in between left the router's - // copy reading "no document" on both sides, so the sheet stayed open. - const liveParams = useUrlSearch(); - const rawDocParam = liveParams.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, currentUrlSearch(), id); - if (href === null) return; - handedOffDocumentRef.current = null; - setDetailId(id); - setDetailOpen(true); - writeUrlState((params) => params.set("doc", id), { - mode: "push", - state: documentSelectionHistoryState(id), - }); - }, - [pathname], - ); - - 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( - urlStateHistory, - pathname, - currentUrlSearch(), - 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(currentUrlSearch(), docParam)) { - return; - } - writeUrlState((params) => params.delete("doc")); - }, CLOSE_TRAVERSAL_GRACE_MS); - return () => window.clearTimeout(verify); - }, [ - authLoading, - coachOwnsDocument, - coachLaunch?.closeIntent, - detailOpen, - docParam, + const { + filters, + searchDraft, + setSearchDraft, + searchInputRef, + toggleKind, + toggleEpisode, + toggleEncounter, + toggleYear, + clearFilters, + } = useVaultFilters(); + const { + usage, + list, + documents, + layout, + isLayoutLoading, + pull, + upload, + uploadInputRef, + uploadAnnouncement, + enqueueFiles, + dropActive, + episodes, + reindexAll, + showIndexAll, + handleIndexAll, + conditionChips, + bulkEncounterOptions, + procedureChips, + years, + } = useVaultLibrary({ + filters, moduleEnabled, - pathname, - ]); - - // 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], - ); + canManageDocuments, + inSharedRecord, + sections, + timezone, + }); + const { + selectedIds, + toggleSelected, + clearSelection, + bulk, + runBulk, + pendingDeleteIds, + setPendingDeleteIds, + requestDelete, + confirmDelete, + bulkShareOpen, + setBulkShareOpen, + bulkShareDocs, + openBulkShare, + } = useVaultSelection(documents); + const { + detailId, + detailOpen, + openDetail, + handleDetailOpenChange, + prefetchDetail, + } = useVaultDetail({ authLoading, moduleEnabled, pathname }); if (authLoading || !isAuthenticated || !moduleEnabled) { return ( @@ -978,45 +164,12 @@ export function DocumentsView() { description={t("documents.subtitle")} actions={ canManageDocuments || !inSharedRecord ? ( -
- {/* 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 } /> @@ -1194,27 +347,7 @@ export function DocumentsView() { documents={bulkShareDocs} /> - {/* 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} + {dropActive ? : null}

{uploadAnnouncement}

diff --git a/src/components/documents/vault/selection-history.ts b/src/components/documents/vault/selection-history.ts new file mode 100644 index 000000000..116f1a539 --- /dev/null +++ b/src/components/documents/vault/selection-history.ts @@ -0,0 +1,148 @@ +import type { CoachCloseIntent } from "@/lib/insights/coach-launch-context"; + +/** + * The open document lives in the URL as `?doc=`, pushed as its own + * history entry when a card opens it, so Back closes the sheet and a link + * reopens it. These helpers own that entry: building the URL, recognising + * the entry a card pushed, consuming it on close, and the one close (the + * Coach handoff) that keeps it as the way back. + */ +// Strict end-of-input: `$` also accepts a trailing newline in JavaScript. +export 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; +} diff --git a/src/components/documents/vault/use-vault-bulk.ts b/src/components/documents/vault/use-vault-bulk.ts new file mode 100644 index 000000000..875551303 --- /dev/null +++ b/src/components/documents/vault/use-vault-bulk.ts @@ -0,0 +1,176 @@ +"use client"; + +import { useCallback } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { apiPost } from "@/lib/api/api-fetch"; +import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import type { + DocumentBulkAction, + DocumentBulkResultDto, + InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; + +/** + * The bulk actions on a selection: re-file and link (one outcome toast), and + * delete with one undo for the whole batch. Deleting is only ever reached + * through the page's confirmation. + */ +export function useVaultBulk({ + selectedIds, + clearSelection, +}: { + selectedIds: ReadonlySet; + clearSelection: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + /** + * 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) { + showErrorToast( + 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: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), + }, + ); + }, + [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: (failure) => + showErrorToast(t("documents.toast.restoreFailed"), { + cause: failure, + }), + }, + ); + }, + [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) { + showErrorToast( + 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: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), + }, + ); + }, + [bulk, clearSelection, restoreBulk, t], + ); + + return { bulk, runBulk, deleteBulk }; +} diff --git a/src/components/documents/vault/use-vault-detail.ts b/src/components/documents/vault/use-vault-detail.ts new file mode 100644 index 000000000..7c5854a2f --- /dev/null +++ b/src/components/documents/vault/use-vault-detail.ts @@ -0,0 +1,180 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { queryKeys } from "@/lib/query-keys"; +import { + currentUrlSearch, + urlStateHistory, + useUrlSearch, + writeUrlState, +} from "@/lib/url-state"; +import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; + +import { + CLOSE_TRAVERSAL_GRACE_MS, + closeDocumentSelectionAfterCoachHandoff, + coachHandoffRetainsSelection, + DOCUMENT_QUERY_ID_PATTERN, + documentSelectionHistoryState, + documentSelectionHref, + documentSelectionSurvivedClose, +} from "./selection-history"; + +/** + * The detail sheet, driven by `?doc=`: a card pushes the selection, Back and + * Forward and direct links drive the same state without remounting the + * route, and a close consumes the pushed entry (and checks that it did). + */ +export function useVaultDetail({ + authLoading, + moduleEnabled, + pathname, +}: { + authLoading: boolean; + moduleEnabled: boolean; + pathname: string; +}) { + const queryClient = useQueryClient(); + const coachLaunch = useCoachLaunch(); + const [detailId, setDetailId] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const handedOffDocumentRef = useRef(null); + + // `?doc=` read live (`@/lib/url-state`): Next 16.4 adopts a pushState a + // render after the write, and a Back pressed in between left the router's + // copy reading "no document" on both sides, so the sheet stayed open. + const liveParams = useUrlSearch(); + const rawDocParam = liveParams.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, currentUrlSearch(), id); + if (href === null) return; + handedOffDocumentRef.current = null; + setDetailId(id); + setDetailOpen(true); + writeUrlState((params) => params.set("doc", id), { + mode: "push", + state: documentSelectionHistoryState(id), + }); + }, + [pathname], + ); + + 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( + urlStateHistory, + pathname, + currentUrlSearch(), + 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(currentUrlSearch(), docParam)) { + return; + } + writeUrlState((params) => params.delete("doc")); + }, CLOSE_TRAVERSAL_GRACE_MS); + return () => window.clearTimeout(verify); + }, [ + authLoading, + coachOwnsDocument, + coachLaunch?.closeIntent, + detailOpen, + docParam, + moduleEnabled, + pathname, + ]); + + // 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], + ); + + return { + detailId, + detailOpen, + openDetail, + handleDetailOpenChange, + prefetchDetail, + }; +} diff --git a/src/components/documents/vault/use-vault-filter-choices.ts b/src/components/documents/vault/use-vault-filter-choices.ts new file mode 100644 index 000000000..3c40963ec --- /dev/null +++ b/src/components/documents/vault/use-vault-filter-choices.ts @@ -0,0 +1,138 @@ +"use client"; + +import { useMemo } from "react"; + +import { + bodySiteText, + encounterKindText, +} from "@/components/encounters/encounter-labels"; +import type { EncounterKind } from "@/generated/prisma/client"; +import { useEncounters } from "@/hooks/use-encounters"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + DocumentUsageDto, + InboundDocumentDto, +} from "@/lib/validations/inbound-documents"; + +import type { ConditionChip } from "../document-filter-bar"; +import { + buildProcedureChoices, + documentDateKey, + type ProcedureFilterChoice, +} from "../vault-utils"; + +import type { VaultFilters } from "./use-vault-filters"; + +/** + * 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; + +/** + * The choices the filter bar and the bulk bar offer: conditions and + * procedures with linked documents, the visits a selection can be filed + * against, and the years present. + */ +export function useVaultFilterChoices({ + filters, + usage, + episodes, + documents, + timezone, + inSharedRecord, + sections, +}: { + filters: VaultFilters["filters"]; + usage: DocumentUsageDto | undefined; + episodes: Array<{ id: string; label: string }> | undefined; + documents: InboundDocumentDto[]; + timezone: string; + inSharedRecord: boolean; + sections: readonly string[] | null; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + // 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?.linkedEpisodes ?? []) { + byId.set(link.episodeId, link.name); + } + if (filters.episodeId && !byId.has(filters.episodeId)) { + const episode = episodes?.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?.linkedEpisodes, filters.episodeId, episodes]); + + // 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?.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?.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]); + + return { conditionChips, bulkEncounterOptions, procedureChips, years }; +} diff --git a/src/components/documents/vault/use-vault-filters.ts b/src/components/documents/vault/use-vault-filters.ts new file mode 100644 index 000000000..ab1aa697f --- /dev/null +++ b/src/components/documents/vault/use-vault-filters.ts @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; + +import { parseVaultSearchParams, vaultFiltersToSearch } from "../vault-utils"; + +/** + * The vault's URL-owned filter state (`?q&kind&episode&encounter&year`), so + * every view is shareable, back-button-safe and deep-linkable. The search + * draft debounces into the URL and `/` focuses it from anywhere on the page. + */ +export function useVaultFilters() { + // 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; + // Live filters, not the rendered ones a "Clear filters" just removed. + applyFilters( + (current) => + trimmed === (current.q ?? "") + ? current + : { ...current, 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); + }, []); + + // Every facet toggles against the live filters (`applyFilters` updater). + const toggleKind = (kind: InboundDocumentKindValue) => { + applyFilters((current) => { + const kinds = new Set(current.kinds ?? []); + if (kinds.has(kind)) kinds.delete(kind); + else kinds.add(kind); + return { + ...current, + kinds: kinds.size > 0 ? [...kinds].sort() : undefined, + }; + }, "push"); + }; + + const toggleEpisode = (episodeId: string) => { + applyFilters( + (current) => ({ + ...current, + episodeId: current.episodeId === episodeId ? undefined : episodeId, + }), + "push", + ); + }; + + const toggleEncounter = (encounterId: string) => { + applyFilters( + (current) => ({ + ...current, + encounterId: + current.encounterId === encounterId ? undefined : encounterId, + }), + "push", + ); + }; + + const toggleYear = (year: number) => { + applyFilters( + (current) => ({ + ...current, + year: current.year === year ? undefined : year, + }), + "push", + ); + }; + + const clearFilters = () => { + setSearchDraft(""); + applyFilters({}, "push"); + }; + + return { + filters, + searchDraft, + setSearchDraft, + searchInputRef, + toggleKind, + toggleEpisode, + toggleEncounter, + toggleYear, + clearFilters, + }; +} + +export type VaultFilters = ReturnType; diff --git a/src/components/documents/vault/use-vault-library.ts b/src/components/documents/vault/use-vault-library.ts new file mode 100644 index 000000000..bfa31c406 --- /dev/null +++ b/src/components/documents/vault/use-vault-library.ts @@ -0,0 +1,205 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useIllnessEpisodes } from "@/components/illness/use-illness"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { apiGet } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { useDocumentsLayout } from "@/lib/queries/use-documents-layout"; +import { queryKeys } from "@/lib/query-keys"; +import type { + DocumentUsageDto, + InboundDocumentDto, +} from "@/lib/validations/inbound-documents"; + +import { useReindexAll } from "../use-content-index"; +import { useDocumentUpload } from "../use-document-upload"; +import { usePageFileDrop } from "../use-page-file-drop"; +import { buildVaultListApiSearch, hasProcessingDocument } from "../vault-utils"; + +import type { VaultFilters } from "./use-vault-filters"; +import { useVaultFilterChoices } from "./use-vault-filter-choices"; + +interface ListPage { + documents: InboundDocumentDto[]; + nextCursor: string | null; +} + +/** + * Everything the vault shows: the usage gauge, the paged list for the active + * filters, the reader's layout, the upload queue and its intake paths, the + * corpus-index affordance, and the filter choices (conditions, procedures, + * visits for the bulk bar, years). + */ +export function useVaultLibrary({ + filters, + moduleEnabled, + canManageDocuments, + inSharedRecord, + sections, + timezone, +}: { + filters: VaultFilters["filters"]; + moduleEnabled: boolean; + canManageDocuments: boolean; + inSharedRecord: boolean; + sections: readonly string[] | null; + timezone: string; +}) { + const { t } = useTranslations(); + 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, + // .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); + + // 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: (failure) => + showErrorToast(t("documents.contentIndex.indexAllError"), { + cause: failure, + }), + }); + }, [reindexAll, t]); + + const { conditionChips, bulkEncounterOptions, procedureChips, years } = + useVaultFilterChoices({ + filters, + usage: usage.data, + episodes: episodes.data, + documents, + timezone, + inSharedRecord, + sections, + }); + + return { + usage, + list, + documents, + layout, + isLayoutLoading, + pull, + upload, + uploadInputRef, + uploadAnnouncement, + enqueueFiles, + dropActive, + episodes, + reindexAll, + showIndexAll, + handleIndexAll, + conditionChips, + bulkEncounterOptions, + procedureChips, + years, + }; +} + +export type VaultLibrary = ReturnType; diff --git a/src/components/documents/vault/use-vault-selection.ts b/src/components/documents/vault/use-vault-selection.ts new file mode 100644 index 000000000..c528ed7f7 --- /dev/null +++ b/src/components/documents/vault/use-vault-selection.ts @@ -0,0 +1,134 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +import { + expandRangeSelection, + resolveBulkShareDocuments, + SHARE_LINK_MAX_DOCUMENTS, +} from "../vault-utils"; + +import { useVaultBulk } from "./use-vault-bulk"; + +/** + * The selection (with shift-click ranges, cleared by Escape unless a dialog + * or menu owns the key) and what can be done with it: re-file, link, share + * as one link, and delete, which always asks first and offers one undo for + * the whole batch. + */ +export function useVaultSelection(documents: InboundDocumentDto[]) { + const { t } = useTranslations(); + 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]); + + const { bulk, runBulk, deleteBulk } = useVaultBulk({ + selectedIds, + clearSelection, + }); + + // ── 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) { + showErrorToast( + t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }), + ); + return; + } + if (resolved.documents.length === 0) return; + setBulkShareDocs(resolved.documents); + setBulkShareOpen(true); + }, [documents, selectedIds, t]); + + return { + selectedIds, + toggleSelected, + clearSelection, + bulk, + runBulk, + pendingDeleteIds, + setPendingDeleteIds, + requestDelete, + confirmDelete, + bulkShareOpen, + setBulkShareOpen, + bulkShareDocs, + openBulkShare, + }; +} + +export type VaultSelection = ReturnType; diff --git a/src/components/documents/vault/vault-drop-overlay.tsx b/src/components/documents/vault/vault-drop-overlay.tsx new file mode 100644 index 000000000..7deb67648 --- /dev/null +++ b/src/components/documents/vault/vault-drop-overlay.tsx @@ -0,0 +1,30 @@ +import { UploadCloud } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * The page-wide drop overlay. Pure decoration (aria-hidden): the intake + * announces through the page's live region. + */ +export function VaultDropOverlay() { + const { t } = useTranslations(); + return ( +
+
+
+ +

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

+

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

+
+
+
+ ); +} diff --git a/src/components/documents/vault/vault-page-actions.tsx b/src/components/documents/vault/vault-page-actions.tsx new file mode 100644 index 000000000..e2b511676 --- /dev/null +++ b/src/components/documents/vault/vault-page-actions.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { RefObject } from "react"; +import Link from "next/link"; +import { Upload, Wrench } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +import { ImportFromSourceButton } from "../sources/import-from-source-button"; + +/** + * The page header's actions (rendered only when one of them applies): the display-settings wrench (the caller's own + * settings, so never inside somebody else's record), the source import and + * the upload button, which an empty vault's empty state carries instead. + */ +export function VaultPageActions({ + canManageDocuments, + inSharedRecord, + hideUpload, + uploadInputRef, +}: { + canManageDocuments: boolean; + inSharedRecord: boolean; + hideUpload: boolean; + uploadInputRef: RefObject; +}) { + const { t } = useTranslations(); + 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. */} + {hideUpload ? null : ( + + )} + + ) : null} +
+ ); +}