mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
This commit is contained in:
1 parent
3c0063edb2
commit
3eb5aa9364
19 files changed
+1328
-972
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,7 +89,7 @@ const INVENTORY: Readonly<Record<string, Poll>> = {
|
||||
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:
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ const ALLOWED: Record<string, string> = {
|
||||
"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`",
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
documentSelectionHref,
|
||||
documentSelectionSurvivedClose,
|
||||
withoutDocumentSelectionHref,
|
||||
} from "../documents-view";
|
||||
} from "../vault/selection-history";
|
||||
|
||||
class MemoryHistory {
|
||||
private entries: { href: string; state: unknown }[];
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,148 @@
|
||||
import type { CoachCloseIntent } from "@/lib/insights/coach-launch-context";
|
||||
|
||||
/**
|
||||
* The open document lives in the URL as `?doc=<id>`, 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<string, unknown> {
|
||||
// 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<string, unknown>)[
|
||||
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<typeof closeDocumentSelectionHistoryEntry>[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;
|
||||
}
|
||||
@@ -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<string>;
|
||||
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<DocumentBulkAction, "delete" | "restore">,
|
||||
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 };
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const handedOffDocumentRef = useRef<string | null>(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<string | null>(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<InboundDocumentDetailDto>(`/api/documents/inbound/${id}`),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
detailId,
|
||||
detailOpen,
|
||||
openDetail,
|
||||
handleDetailOpenChange,
|
||||
prefetchDetail,
|
||||
};
|
||||
}
|
||||
@@ -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<ConditionChip[]>(() => {
|
||||
const byId = new Map<string, string>();
|
||||
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<ProcedureFilterChoice[]>(() => {
|
||||
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<number>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<HTMLInputElement | null>(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<typeof useVaultFilters>;
|
||||
@@ -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<DocumentUsageDto>("/api/documents/inbound/usage"),
|
||||
});
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: queryKeys.inboundDocumentList(filters),
|
||||
enabled: moduleEnabled,
|
||||
queryFn: ({ pageParam }) =>
|
||||
apiGet<ListPage>(
|
||||
`/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<HTMLInputElement | null>(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<typeof useVaultLibrary>;
|
||||
@@ -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<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
// Anchor for shift-click ranges: the last plainly-toggled id.
|
||||
const rangeAnchorRef = useRef<string | null>(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<string[] | null>(
|
||||
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<typeof useVaultSelection>;
|
||||
@@ -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 (
|
||||
<div
|
||||
aria-hidden
|
||||
data-slot="document-drop-overlay"
|
||||
className="bg-background/80 fixed inset-0 z-50 p-4 backdrop-blur-sm md:p-8"
|
||||
>
|
||||
<div className="border-primary bg-primary/5 flex h-full items-center justify-center rounded-xl border-2 border-dashed">
|
||||
<div className="flex flex-col items-center gap-2 px-4 text-center">
|
||||
<UploadCloud className="text-primary size-8" aria-hidden />
|
||||
<p className="text-base font-medium">
|
||||
{t("documents.dropOverlay.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.dropOverlay.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* 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 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/documents"
|
||||
aria-label={t("documents.customize")}
|
||||
title={t("documents.customize")}
|
||||
data-slot="documents-customize"
|
||||
>
|
||||
<Wrench className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageDocuments ? (
|
||||
<>
|
||||
{/* Renders only with a connected Paperless-ngx or Papra. */}
|
||||
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
|
||||
{/* An empty vault's empty state carries the upload. */}
|
||||
{hideUpload ? null : (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.pageUpload")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user