refactor(documents): split the document detail sheet

The sheet becomes a composer over detail/: the original-file preview and
download controls, the detail read with its writes, the AI layer with its
gate and review-first apply, and the field, meta and footer sections.
Guards that read the sheet's source now read the detail folder.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:11:20 +02:00
1 parent ac77e52e77
commit 7c1ed25915
16 files changed
+1447 -1046

No files matched your search

@@ -139,7 +139,7 @@ const AI_REQUEST_SITES: Record<string, RequestSite> = {
},
"components/documents/use-document-assist.ts": {
capability: "documentAi",
gate: "components/documents/document-detail-sheet.tsx",
gate: "components/documents/detail/use-document-detail-ai.ts",
},
};
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 115,
"total": 132921,
"count": 114,
"total": 131617,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -15,7 +15,6 @@
"components/admin/backups-section.tsx": 1462,
"components/charts/mood-chart.tsx": 1166,
"components/cycle/log-day-sheet.tsx": 1639,
"components/documents/document-detail-sheet.tsx": 1304,
"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": 401,
"total": 138066,
"total": 137456,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -121,7 +121,7 @@
"components/documents/document-ai-section.tsx :: DocumentAiSection": 262,
"components/documents/document-bulk-bar.tsx :: DocumentBulkBar": 204,
"components/documents/document-card.tsx :: DocumentCard": 354,
"components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 935,
"components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325,
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 252,
"components/documents/document-facts-review.tsx :: FactRow": 262,
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 360,
"total": 360,
"count": 359,
"total": 359,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -190,7 +190,6 @@
"components/day/__tests__/day-door-coverage-guard.test.ts": 1,
"components/day/__tests__/day-not-here-guard.test.ts": 1,
"components/documents/__tests__/document-coach-launch-wiring.test.ts": 1,
"components/documents/__tests__/document-detail-download.test.ts": 1,
"components/documents/__tests__/documents-delete-confirm-guard.test.ts": 1,
"components/forms/__tests__/use-seeded-form-dismissal.test.ts": 1,
"components/i18n/__tests__/maintainership-banner.test.tsx": 1,
@@ -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": 2947,
"total": 10231,
"count": 2946,
"total": 10225,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -833,7 +833,6 @@
"components/documents/document-ai-transport.ts": 2,
"components/documents/document-bulk-bar.tsx": 1,
"components/documents/document-card.tsx": 2,
"components/documents/document-detail-sheet.tsx": 6,
"components/documents/document-facts-review.tsx": 2,
"components/documents/document-summary-block.tsx": 2,
"components/documents/use-content-index.ts": 1,
@@ -598,8 +598,9 @@ const REGISTRY: DestructiveEntry[] = [
// ── Documents ───────────────────────────────────────────────────────────
{
file: "components/documents/document-detail-sheet.tsx",
file: "components/documents/detail/use-document-detail.ts",
destroys: "one document in the vault",
triggers: ["components/documents/detail/detail-sections.tsx"],
recovery: "restorable",
confirm: [],
unconfirmed: [
@@ -0,0 +1,20 @@
/**
* The document detail sheet's source, for the checks that pin how it is
* wired: the sheet (`document-detail-sheet.tsx`) and the hooks and sections
* it is built from (`documents/detail/`). Read together, so a rule about the
* sheet holds wherever in those modules the code sits. The folder read has a
* floor and the sheet an anchor, so a rename fails instead of reading less.
*/
import { readSource, readSourceFolder } from "./source-files";
export function documentDetailSource(): string {
return [
readSource(
"src/components/documents/document-detail-sheet.tsx",
"DocumentDetailSheet",
),
...readSourceFolder("src/components/documents/detail", { floor: 4 }).map(
([, text]) => text,
),
].join("\n");
}
@@ -176,7 +176,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/detail/use-document-detail-ai.ts": {
"toast.success": 1,
},
"src/components/documents/detail/use-document-detail.ts": {
"toast.success": 1,
},
"src/components/documents/vault/use-vault-library.ts": {
"toast.success": 1,
},
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { documentDetailSource } from "@/__tests__/helpers/document-detail-source";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -15,10 +16,6 @@ import { join } from "node:path";
* stays connected end-to-end.
*/
const ROOT = join(__dirname, "../../../..");
const DETAIL_SHEET = join(
ROOT,
"src/components/documents/document-detail-sheet.tsx",
);
const MOUNT = join(ROOT, "src/components/insights/layout-coach-mount.tsx");
const DRAWER = join(
ROOT,
@@ -30,7 +27,7 @@ function load(path: string): string {
}
describe("document detail sheet — Ask the Coach opens the drawer", () => {
const src = load(DETAIL_SHEET);
const src = documentDetailSource();
it("launches the doc-scoped coach drawer via askCoach(..., doc.id)", () => {
expect(src).toContain("useCoachLaunch");
@@ -21,13 +21,10 @@
* disabled state is real. A render could not see either.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SHEET = readFileSync(
join(process.cwd(), "src/components/documents/document-detail-sheet.tsx"),
"utf8",
);
import { documentDetailSource } from "@/__tests__/helpers/document-detail-source";
const SHEET = documentDetailSource();
describe("the detail sheet's original-document transport", () => {
it("has exactly one eager loader for the original", () => {
@@ -16,13 +16,15 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { documentDetailSource } from "@/__tests__/helpers/document-detail-source";
const dir = join(process.cwd(), "src/components/documents");
// 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 sheet = documentDetailSource();
const bar = readFileSync(join(dir, "document-bulk-bar.tsx"), "utf8");
describe("vault delete paths confirm before deleting", () => {
@@ -0,0 +1,409 @@
"use client";
import Link from "next/link";
import {
ArrowRight,
Check,
FlaskConical,
Pencil,
Plus,
Share2,
Sparkles,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { INBOUND_DOCUMENT_KINDS } from "@/lib/documents/inbound-constants";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { cn } from "@/lib/utils";
import type {
InboundDocumentDetailDto,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import {
DocumentDownloadButton,
DocumentOpenInTabButton,
} from "./original-file";
import type { DocumentDetail } from "./use-document-detail";
/**
* The sheet's footer. Delete sits bottom-left as a quiet text button (the
* bottom-right slot reads as the confirm action, and a destructive control
* there is a footgun); Coach, Share, open in a new tab and Download keep the
* trailing edge, icon-only on phones so they never overflow at 360 px.
*/
export function DocumentDetailFooter({
doc,
detail,
aiEnabled,
coachAvailable,
onOpenChange,
}: {
doc: InboundDocumentDetailDto;
detail: DocumentDetail;
aiEnabled: boolean;
coachAvailable: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
// The vault's "Ask the Coach" opens the fenced conversation scoped to this
// document in the shared side drawer.
const launch = useCoachLaunch();
const {
canManageDocuments,
setConfirmDeleteOpen,
remove,
setShareOpen,
inlineSrc,
inlineFailed,
} = detail;
return (
<div className="flex w-full items-center justify-between gap-2">
{/* .x — deleting, sharing and re-filing a document are the
owner's alone. Inside somebody else's record the sheet reads
the document and offers the download, and the leading slot
simply has nothing in it. */}
{canManageDocuments ? (
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setConfirmDeleteOpen(true)}
disabled={remove.isPending}
data-slot="document-detail-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
{/* .x — `launch` is the one entry point that rendered
without asking whether a drawer exists to open. It is null
inside somebody else's record (the shell mounts no drawer
there), and `/documents` is a shared-record destination, so
this is the Coach button a delegate actually meets. */}
{aiEnabled && coachAvailable && launch ? (
// "Ask the Coach" opens the REAL
// coach conversation scoped to this document in the SIDE
// DRAWER (the maximize control there expands it to the full
// `/coach` page). Every doc turn still runs on the hardened
// fenced document endpoint (never the coach tool route) — only
// WHERE the conversation renders changed, not the send path.
// Close the detail sheet so the drawer owns the surface.
<Button
variant="outline"
onClick={() => {
onOpenChange(false);
launch.askCoach(null, undefined, false, doc.id);
}}
data-slot="document-chat-open"
>
<Sparkles className="size-4" aria-hidden />
<span className="sr-only sm:not-sr-only">
{t("documents.chat.openButton")}
</span>
</Button>
) : null}
{canManageDocuments && (
<Button
variant="outline"
onClick={() => setShareOpen(true)}
data-slot="document-share"
aria-label={t("documents.detail.share")}
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.share")}
</span>
</Button>
)}
{doc.servingClass === "inline" ? (
<DocumentOpenInTabButton
readyUrl={inlineSrc}
unavailable={inlineFailed}
/>
) : null}
<DocumentDownloadButton
documentId={doc.id}
filename={doc.filename}
readyUrl={inlineSrc}
unavailable={inlineFailed}
variant="outline"
slot="document-download"
/>
</div>
</div>
);
}
/** Title, kind, filing date and the condition links, each saving on commit. */
export function DocumentDetailFields({
doc,
detail,
}: {
doc: InboundDocumentDetailDto;
detail: DocumentDetail;
}) {
const { t } = useTranslations();
const {
canManageDocuments,
editingTitle,
setEditingTitle,
titleDraft,
setTitleDraft,
commitTitle,
patch,
episodes,
toggleEpisode,
} = detail;
return (
<>
<div className="space-y-1.5">
<Label htmlFor="document-title-input">
{t("documents.detail.titleLabel")}
</Label>
{editingTitle ? (
<Input
id="document-title-input"
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") commitTitle();
if (e.key === "Escape") setEditingTitle(false);
}}
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : !canManageDocuments ? (
<p
id="document-title-input"
className={cn(
"min-h-10 px-3 py-2 text-sm",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</p>
) : (
<button
type="button"
id="document-title-input"
onClick={() => {
setTitleDraft(doc.title ?? "");
setEditingTitle(true);
}}
className="border-input hover:bg-muted/50 focus-visible:border-input-focus flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:outline-none"
>
<span
className={cn(
"truncate",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</span>
<Pencil
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden
/>
</button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="document-kind-select">
{t("documents.detail.kindLabel")}
</Label>
{/* Read-only rather than absent: the value IS the content
a delegate came to read, and a form field that shows it
without accepting a change is the honest rendering. */}
<Select
value={doc.kind}
disabled={!canManageDocuments}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
>
<SelectTrigger id="document-kind-select" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOUND_DOCUMENT_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`documents.kind.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-date-field">
{t("documents.detail.dateLabel")}
</Label>
<DateField
id="document-date-field"
disabled={!canManageDocuments}
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({
documentDate: value === "" ? null : value,
})
}
aria-label={t("documents.detail.dateLabel")}
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-sm leading-none font-medium">
{t("documents.detail.conditionsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.conditionLinks.map((link) => (
<Link
key={link.episodeId}
href={`/illness/${link.episodeId}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">{link.name}</span>
</Link>
))}
{doc.conditionLinks.length === 0 ? (
<span className="text-muted-foreground text-xs">
{t("documents.detail.noConditions")}
</span>
) : null}
{canManageDocuments && (episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 rounded-full px-2.5 text-xs"
>
<Plus className="size-3" aria-hidden />
{t("documents.detail.linkCondition")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
{episodes.data?.map((episode) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episode.id,
);
return (
<li key={episode.id}>
<button
type="button"
onClick={() => toggleEpisode(episode.id)}
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
aria-pressed={linked}
>
<Check
className={cn(
"size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="truncate">{episode.label}</span>
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
) : null}
</div>
</div>
</>
);
}
/**
* The lab values read from this page, when Labs holds any, and the upload,
* filename and import provenance meta lines.
*/
export function DocumentDetailMeta({
doc,
detail,
}: {
doc: InboundDocumentDetailDto;
detail: DocumentDetail;
}) {
const { t, locale } = useTranslations();
const format = useFormatters();
const { labsModuleEnabled, labFactCount, sourceSystemName } = detail;
return (
<>
{labsModuleEnabled && labFactCount > 0 ? (
<Link
href="/labs"
data-slot="document-detail-lab-values-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 self-start rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FlaskConical className="size-4" aria-hidden="true" />
{t("documents.detail.labValuesLink", { count: labFactCount })}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
size: formatBytes(doc.byteSize, locale),
})}
</p>
{doc.filename ? (
<p
className="text-muted-foreground truncate text-xs"
title={doc.filename}
>
{doc.filename}
</p>
) : null}
{/* Where an imported document came from (#1038). Meta, so
muted like the line above; the id is what the person looks
up in the other system. */}
{doc.sourceSystem ? (
<p
className="text-muted-foreground text-xs break-all"
data-slot="document-detail-provenance"
>
{doc.sourceId
? t("documents.detail.importedFromWithId", {
system: sourceSystemName(doc.sourceSystem),
id: doc.sourceId,
})
: t("documents.detail.importedFrom", {
system: sourceSystemName(doc.sourceSystem),
})}
</p>
) : null}
</>
);
}
@@ -0,0 +1,263 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Download, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { createFencedBlobLoader } from "@/hooks/use-fenced-object-url";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* Download the stored original.
*
* A real button, not an anchor wearing `aria-disabled`.
*
* Two things were wrong with the anchor. `aria-disabled` tells a screen reader
* the control is unavailable and stops nothing: `href="#"` is still activated
* by a click and by Enter, so a person got a jump to the top of the page
* instead of a file. And it ignored the failure flag entirely, so a document
* whose original could not be fetched left a control that looked fine and
* silently did nothing, for good.
*
* It also fetches at CLICK time unless the bytes are already in hand. The
* sheet loads the original eagerly only for documents it is going to render
* inline; for everything else — a large scan, a download-only format — nothing
* is fetched or decrypted until somebody asks for it, which is what the plain
* `href` used to cost and what the first fix accidentally gave up.
*/
export function DocumentDownloadButton({
documentId,
filename,
readyUrl,
unavailable,
slot,
...buttonProps
}: {
documentId: string;
filename: string | null;
/** Already-fetched bytes to reuse (the inline preview's), or null. */
readyUrl: string | null;
/** A prior fetch of these bytes failed for good. */
unavailable: boolean;
slot: string;
} & React.ComponentProps<typeof Button>) {
const { t } = useTranslations();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const loaderRef = useRef<ReturnType<typeof createFencedBlobLoader> | null>(
null,
);
useEffect(() => () => loaderRef.current?.dispose(), []);
const save = (url: string) => {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename ?? "document";
anchor.click();
};
const onClick = async () => {
if (readyUrl) {
save(readyUrl);
return;
}
setPending(true);
setFailed(false);
// One loader for the lifetime of the button: loading again revokes the
// previous object URL, and unmounting revokes the live one. Without that a
// decrypted health document stays in the tab's memory until it closes.
loaderRef.current ??= createFencedBlobLoader();
const result = await loaderRef.current.load(
`/api/documents/inbound/${documentId}/original`,
);
setPending(false);
if (result.kind === "loaded") save(result.url);
else if (result.kind === "failed") setFailed(true);
};
const blocked = unavailable || failed;
return (
<Button
{...buttonProps}
data-slot={slot}
disabled={blocked || pending}
aria-label={t("documents.detail.download")}
title={blocked ? t("documents.detail.loadError") : undefined}
onClick={() => void onClick()}
>
<Download className="size-4" aria-hidden />
<span className="hidden sm:inline">
{blocked
? t("documents.detail.loadError")
: t("documents.detail.download")}
</span>
</Button>
);
}
/**
* Open the stored original in a new browser tab, for the types the browser
* renders itself (the inline serving class: PDF, JPEG, PNG, WebP, GIF), so a
* long report gets the browser's own zoom and search.
*
* It opens the object URL the sheet already holds rather than the route: a
* plain navigation carries no record-session assertion, so the fence would
* refuse it once the session has been inside a shared record. The bytes are
* already in hand, so the tab opens inside the click and no popup blocker
* steps in. A type outside the inline class never gets the control.
*/
export function DocumentOpenInTabButton({
readyUrl,
unavailable,
}: {
readyUrl: string | null;
unavailable: boolean;
}) {
const { t } = useTranslations();
return (
<Button
variant="outline"
data-slot="document-open-in-tab"
disabled={readyUrl === null || unavailable}
aria-label={t("documents.detail.openInNewTab")}
onClick={() => {
if (readyUrl) window.open(readyUrl, "_blank", "noopener");
}}
>
<ExternalLink className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.openInNewTab")}
</span>
</Button>
);
}
/** Inline preview for Class A documents; skeleton until the blob paints. */
export function InlinePreview({
src,
failed,
mimeType,
title,
}: {
/**
* The object URL, fetched ONCE by the sheet and handed down.
*
* The bytes come through the app transport because it carries the
* record-session assertion and a browser-issued `src="/api/…"` cannot. What
* changed since: this component used to fetch them itself while the sheet
* fetched the SAME original again for the download link, so opening a
* document decrypted and held two copies of it — and a large scan is a large
* copy. One loader now, lifted to the sheet.
*/
src: string | null;
failed: boolean;
mimeType: string;
title: string;
}) {
const [loaded, setLoaded] = useState(false);
const isPdf = mimeType === "application/pdf";
// Chromium's PDF viewer grabs focus while it initialises — BEFORE the
// frame's `load` event — stranding keyboard events inside the embedded
// document, so Escape stops closing the sheet the moment a PDF preview
// opens. While the guard window is open (armed at mount, extended at
// load, ended early by a deliberate pointer interaction with the
// preview), any focus landing on the frame bounces straight back to the
// dialog container; a user who clicks into the PDF is left alone.
const frameRef = useRef<HTMLIFrameElement | null>(null);
const stealGuardUntil = useRef(0);
const reclaimFocus = (frame: HTMLIFrameElement) => {
if (
Date.now() < stealGuardUntil.current &&
document.activeElement === frame
) {
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
}
};
useEffect(() => {
// Arm at mount, then run a frame-by-frame sweep for the steal variants
// that never dispatch a focus event on the frame element.
// Self-terminates with the guard.
stealGuardUntil.current = Date.now() + 3_000;
let raf = 0;
const sweep = () => {
const frame = frameRef.current;
if (!frame || Date.now() >= stealGuardUntil.current) return;
if (document.activeElement === frame) {
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
}
raf = requestAnimationFrame(sweep);
};
raf = requestAnimationFrame(sweep);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className="relative max-h-[55vh] overflow-auto overscroll-contain rounded-lg">
{failed ? (
// The preview could not be fetched — refused, offline, or still
// rendering server-side. A permanent skeleton would read as "loading
// forever"; the sheet's own metadata and the download control below
// still work.
<p
data-slot="document-preview-unavailable"
className="text-muted-foreground py-8 text-center text-sm"
>
{title}
</p>
) : !loaded || src === null ? (
<Skeleton
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
/>
) : null}
{src === null || failed ? null : isPdf ? (
// No `sandbox` attribute by design: Chromium force-downloads PDFs
// in sandboxed frames instead of rendering them. The serve
// response itself carries `default-src 'none'` +
// `frame-ancestors 'self'` + true Content-Type + nosniff, and the
// upload policy denies every format whose inline render could
// execute script.
<iframe
ref={frameRef}
src={src}
title={title}
onLoad={(event) => {
setLoaded(true);
// The viewer may still initialise (and steal focus) after
// `load` — keep the guard open a little longer.
stealGuardUntil.current = Math.max(
stealGuardUntil.current,
Date.now() + 2_000,
);
reclaimFocus(event.currentTarget);
}}
onFocus={(event) => reclaimFocus(event.currentTarget)}
onPointerDown={() => {
// Deliberate interaction with the preview ends the guard.
stealGuardUntil.current = 0;
}}
className={cn(
"border-border h-[55vh] w-full rounded-lg border",
!loaded && "absolute inset-0 opacity-0",
)}
/>
) : (
// Decrypted same-origin blob stream: next/image cannot optimise it
// and would only proxy the bytes through a second request.
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={title}
onLoad={() => setLoaded(true)}
className={cn(
"mx-auto max-h-[55vh] w-auto max-w-full rounded-lg object-contain",
!loaded && "absolute inset-0 opacity-0",
)}
/>
)}
</div>
);
}
@@ -0,0 +1,325 @@
"use client";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type {
DocumentSuggestionDto,
DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import type {
DocumentAiMode,
DocumentAiTarget,
} from "../document-ai-transport";
import { useIndexDocument } from "../use-content-index";
import {
useDocumentAiCapability,
useDocumentAiErrorText,
useDocumentsAutoAiRead,
useDocumentSummary,
useSuggestDetails,
} from "../use-document-assist";
import type { DocumentDetail } from "./use-document-detail";
/**
* The document's AI layer: the capability that drives the vision or text
* transport, the review-first suggestion and what was applied from it, the
* session-only and the stored summary, and content indexing.
*/
export function useDocumentDetailAi({
documentId,
open,
assistAvailable,
contentIndexEnabled,
detail,
}: {
documentId: string | null;
open: boolean;
assistAvailable?: boolean;
contentIndexEnabled?: boolean;
detail: DocumentDetail;
}) {
const { t, tCount } = useTranslations();
const queryClient = useQueryClient();
const { doc, canManageDocuments } = detail;
// AI layer — capability drives the vision/text transport; the mutations run
// the review-first assist, the session-only summary, and content indexing.
// The affordance itself is gated on `assistAvailable` (from usage); the
// probe only resolves the transport mode + the actionable unavailable reason.
//
// Neither probe fires inside somebody else's record. The
// document reads behind this sheet are delegable; the AI verbs above them are
// not, and both of these routes stay on the caller's own authentication. A
// delegate would fire two queries that must 403 in order to compute an
// affordance every branch below already withholds on `canManageDocuments`.
// Reading a document with AI follows the `documentAi` capability on
// `/api/auth/me` (the operator's "Reading documents" switch, the provider,
// the document-reading consent). Unavailable, the actions are simply not
// offered and the probe never fires; a missing consent is asked for in
// place instead.
const documentAi = useAiCapability("documentAi");
const coach = useAiCapability("coach");
const capability = useDocumentAiCapability(
canManageDocuments && open && documentId !== null && documentAi.available,
);
// When auto-read is ON, reading happens automatically on upload — the manual
// per-document AI action row is redundant and collapses away.
const autoRead = useDocumentsAutoAiRead(
canManageDocuments && open && documentId !== null,
);
const suggest = useSuggestDetails();
const summary = useDocumentSummary();
// Resolves an AI error to final copy — a 429 names the actual wait, derived
// from the reset instant the route mirrors into the error meta.
const aiErrorText = useDocumentAiErrorText();
// Stored generation has an independent mutation state so transient
// Summarise/Show text requests cannot change this block's spinner or result.
const storedSummary = useDocumentSummary();
const indexDoc = useIndexDocument();
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
null,
);
const [appliedFields, setAppliedFields] = useState({
title: false,
kind: false,
date: false,
});
const [summaryOutput, setSummaryOutput] =
useState<DocumentSummaryMode | null>(null);
// Reset transient AI state whenever another document opens.
const [lastDocumentId, setLastDocumentId] = useState(documentId);
if (lastDocumentId !== documentId) {
setLastDocumentId(documentId);
setSuggestion(null);
setAppliedFields({ title: false, kind: false, date: false });
setSummaryOutput(null);
}
// Clear stale mutation state (a prior document's assist error / summary)
// when the sheet switches documents or closes — the mutation stores live
// outside render, so they reset in an effect rather than during render.
useEffect(() => {
suggest.reset();
summary.reset();
storedSummary.reset();
indexDoc.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documentId]);
const { aiEnabled, indexEnabled, autoReadEnabled, aiMode, aiTarget } =
aiGates({
documentAiAvailable: documentAi.available,
assistAvailable,
contentIndexEnabled,
capability: capability.data,
autoRead: autoRead.data,
doc,
});
const runSuggest = () => {
if (!aiTarget) return;
setAppliedFields({ title: false, kind: false, date: false });
suggest.mutate(
{ mode: aiMode, target: aiTarget },
{ onSuccess: (result) => setSuggestion(result) },
);
};
const runSummary = (output: DocumentSummaryMode) => {
if (!aiTarget) return;
summary.reset();
setSummaryOutput(output);
summary.mutate({ mode: aiMode, target: aiTarget, output });
};
// Generate or regenerate the STORED summary from the detail block. Only the
// stored-summary branch requests replacement; the empty-state action keeps
// first-write-wins if another worker or tab finishes while AI is running.
const generateStoredSummary = () => {
if (!aiTarget || !documentId) return;
storedSummary.reset();
storedSummary.mutate(
{
mode: aiMode,
target: aiTarget,
output: "summary",
persist: true,
replace: doc?.summary != null,
},
{
onSuccess: (result) => {
if ("summary" in result) {
if (result.persistence === "withheld") {
showErrorToast(result.summary);
} else if (result.persistence === "failed") {
showErrorToast(t("documents.detail.saveError"));
}
}
void queryClient.invalidateQueries({
queryKey: queryKeys.inboundDocument(documentId),
});
},
onError: (error) =>
showErrorToast(aiErrorText(error), { cause: error }),
},
);
};
const runIndex = () => {
if (!aiTarget) return;
indexDoc.mutate(
{ mode: aiMode, target: aiTarget },
{
// A read that continued into lab staging says so — the staged values
// are waiting in the review block, not silently in a queue. Staged
// facts stay PENDING; confirmation remains the only write into Labs.
onSuccess: (result) =>
toast.success(
result.labFactsStaged > 0
? tCount("documents.ai.readStaged", result.labFactsStaged)
: t("documents.ai.readDone"),
),
onError: (error) =>
showErrorToast(aiErrorText(error), { cause: error }),
},
);
};
const {
applyTitle,
applyKind,
applyDate,
suggestionKindLabel,
suggestionDateLabel,
} = useSuggestionApply({ suggestion, setAppliedFields, detail });
return {
documentAi,
coach,
capability,
suggest,
summary,
storedSummary,
indexDoc,
aiErrorText,
suggestion,
setSuggestion,
appliedFields,
summaryOutput,
setSummaryOutput,
aiEnabled,
indexEnabled,
autoReadEnabled,
runSuggest,
runSummary,
generateStoredSummary,
runIndex,
applyTitle,
applyKind,
applyDate,
suggestionKindLabel,
suggestionDateLabel,
};
}
/**
* Whether the AI actions run, whether content can be indexed, whether auto-
* read already does it, and the transport mode and target. The gate falls
* back to the capability probe when the sheet opens before usage loaded.
*/
function aiGates({
documentAiAvailable,
assistAvailable,
contentIndexEnabled,
capability,
autoRead,
doc,
}: {
documentAiAvailable: boolean;
assistAvailable?: boolean;
contentIndexEnabled?: boolean;
capability: ReturnType<typeof useDocumentAiCapability>["data"];
autoRead: ReturnType<typeof useDocumentsAutoAiRead>["data"];
doc: DocumentDetail["doc"];
}) {
const aiEnabled =
documentAiAvailable && (assistAvailable ?? capability?.available ?? false);
const indexEnabled =
documentAiAvailable &&
(contentIndexEnabled ?? capability?.available ?? false);
const autoReadEnabled = autoRead?.documentsAutoAiRead ?? false;
const aiMode: DocumentAiMode =
capability?.mode === "text" ? "text" : "vision";
const aiTarget: DocumentAiTarget | null = doc
? {
documentId: doc.id,
mimeType: doc.mimeType,
filename: doc.filename,
servingClass: doc.servingClass,
}
: null;
return { aiEnabled, indexEnabled, autoReadEnabled, aiMode, aiTarget };
}
type AppliedFields = { title: boolean; kind: boolean; date: boolean };
/**
* Review-first apply: the title lands in the EDITABLE field (the user still
* saves it via blur/Enter, the existing commit path); type and date each
* commit that single field on an explicit tap. Never automatic.
*/
function useSuggestionApply({
suggestion,
setAppliedFields,
detail,
}: {
suggestion: DocumentSuggestionDto | null;
setAppliedFields: (update: (prev: AppliedFields) => AppliedFields) => void;
detail: DocumentDetail;
}) {
const { t } = useTranslations();
const calendarDate = useCalendarDate();
const { patch, setTitleDraft, setEditingTitle } = detail;
const applyTitle = () => {
if (!suggestion?.title) return;
setTitleDraft(suggestion.title);
setEditingTitle(true);
setAppliedFields((prev) => ({ ...prev, title: true }));
};
const applyKind = () => {
if (!suggestion?.kind) return;
patch.mutate({ kind: suggestion.kind });
setAppliedFields((prev) => ({ ...prev, kind: true }));
};
const applyDate = () => {
if (!suggestion?.documentDate) return;
patch.mutate({ documentDate: suggestion.documentDate });
setAppliedFields((prev) => ({ ...prev, date: true }));
};
const suggestionKindLabel = suggestion?.kind
? t(`documents.kind.${suggestion.kind}`)
: null;
const suggestionDateLabel = suggestion?.documentDate
? calendarDate(suggestion.documentDate)
: null;
return {
applyTitle,
applyKind,
applyDate,
suggestionKindLabel,
suggestionDateLabel,
};
}
@@ -0,0 +1,317 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { useAuth } from "@/hooks/use-auth";
import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
apiDelete,
ApiError,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type {
DocumentSourceSystemValue,
InboundDocumentDetailDto,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "../document-kind-meta";
export type PatchInput = {
title?: string | null;
kind?: InboundDocumentKindValue;
documentDate?: string | null;
episodeIds?: string[];
encounterIds?: string[];
vaccinationIds?: string[];
};
/**
* The open document: its read (always fresh on open, since the link pickers
* write replace-sets seeded from it), the metadata edits that save on commit,
* the soft delete with an Undo that restores it, and the original's bytes for
* a document that previews inline.
*/
export function useDocumentDetail({
documentId,
open,
onOpenChange,
}: {
documentId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const detail = useQuery({
queryKey: queryKeys.inboundDocument(documentId ?? "none"),
enabled: open && documentId !== null,
// Always read again on open: the link pickers below write replace-sets
// seeded from this copy (see `seedFresh`).
staleTime: 0,
queryFn: () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${documentId}`),
});
const doc = detail.data;
// The detail's timestamp when the sheet opened on this document. A read
// that lands after it is fresh enough to seed the link pickers from; a
// cached copy from before the sheet opened is not.
const [openedOn, setOpenedOn] = useState<{
id: string;
seenAt: number;
} | null>(null);
const openKey = open && documentId !== null ? documentId : null;
if (openKey !== (openedOn?.id ?? null)) {
setOpenedOn(
openKey === null ? null : { id: openKey, seenAt: detail.dataUpdatedAt },
);
}
const linkSeedFresh =
openedOn !== null && detail.dataUpdatedAt > openedOn.seenAt;
// The document → lab-values direction
// had no UI link at all: an auto-staged (PENDING) or OCR-confirmed
// (APPROVED, see `linkOcrLabsToVaultDocument`) OBSERVATION fact already
// ties this document to specific lab rows, but nothing on the sheet said
// so. There is no per-value route yet (`/labs` has no `?analyte=`
// deep-link — see the labs page's own "no per-value relation" comment),
// so this links to the labs list rather than a single biomarker; still a
// real jump where there was none before.
//
// APPROVED only: the link claims values that exist in Labs, and a PENDING
// fact is not there yet — it belongs to the review block above, which is
// the control that turns it into one.
const labFactCount =
doc?.facts.filter(
(fact) => fact.factType === "OBSERVATION" && fact.status === "APPROVED",
).length ?? 0;
const { user } = useAuth();
const { canManageDomain } = useRecordCapabilities();
const canManageDocuments = canManageDomain("documents");
const labsModuleEnabled = user?.modules?.labs !== false;
const episodes = useIllnessEpisodes(true);
const [mutationError, setMutationError] = useState<string | null>(null);
/** Bumped on a failed write; remounts the link block onto server state. */
const [linkResets, setLinkResets] = useState(0);
const [shareOpen, setShareOpen] = useState(false);
// Delete asks first, like every other way of deleting from the vault.
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
/**
* The visit this SESSION filed the document against, if any.
*
* Kept beside the server state rather than derived from it, because the two
* answer different questions: `doc.encounterLinks` says what is filed, this
* says what the person just did here — which is what an undo can take back
* without touching a filing they made somewhere else.
*/
const [pickedEncounterId, setPickedEncounterId] = useState<string | null>(
null,
);
const [titleDraft, setTitleDraft] = useState("");
// Reset transient edit state whenever another document opens —
// render-phase derived-state adjustment, not an effect.
const [lastDocumentId, setLastDocumentId] = useState(documentId);
if (lastDocumentId !== documentId) {
setLastDocumentId(documentId);
setEditingTitle(false);
setShareOpen(false);
setMutationError(null);
}
const { patch, remove } = useDocumentWrites({
documentId,
onOpenChange,
setMutationError,
setLinkResets,
});
const commitTitle = () => {
if (!doc) return;
setEditingTitle(false);
const next = titleDraft.trim();
const current = doc.title ?? "";
if (next === current) return;
patch.mutate({ title: next === "" ? null : next });
};
const toggleEpisode = (episodeId: string) => {
if (!doc) return;
const current = doc.conditionLinks.map((l) => l.episodeId);
const next = current.includes(episodeId)
? current.filter((id) => id !== episodeId)
: [...current, episodeId];
patch.mutate({ episodeIds: next });
};
/**
* File this document against a visit, or clear that filing again.
*
* A replace-set PATCH like the condition links, so the undo is the same
* write with the id removed rather than a second endpoint. Nothing here can
* fail the document: an unknown id is refused by the route before anything
* is written, and the sheet keeps showing what the server last confirmed.
*/
const setSuggestedEncounter = (encounterId: string | null) => {
if (!doc) return;
const current = doc.encounterLinks.map((l) => l.encounterId);
const next =
encounterId === null
? current.filter((id) => id !== pickedEncounterId)
: [...new Set([...current, encounterId])];
setPickedEncounterId(encounterId);
patch.mutate({ encounterIds: next });
};
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
// The product names are proper nouns and stay untranslated; only "another
// system" is prose.
const sourceSystemName = (system: DocumentSourceSystemValue): string =>
system === "PAPERLESS"
? "Paperless-ngx"
: system === "PAPRA"
? "Papra"
: t("documents.detail.sourceOther");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
// The ONE eager fetch of the original, and only for a document
// that is going to render it.
//
// A `<iframe src="/api/…">` carries no record-session assertion, so the
// fence refuses it once the session has been inside a shared record; the
// bytes therefore come through the app transport as an object URL. The
// narrow path is the point: a non-inline document (a large scan, a
// download-only format) fetched nothing until somebody clicked, and it must
// stay that way — this used to fetch for every document on open, twice.
const { url: inlineSrc, failed: inlineFailed } = useFencedObjectUrl(
doc && doc.servingClass === "inline"
? `/api/documents/inbound/${doc.id}/original`
: null,
);
return {
detail,
doc,
linkSeedFresh,
labFactCount,
canManageDocuments,
labsModuleEnabled,
episodes,
mutationError,
linkResets,
shareOpen,
setShareOpen,
confirmDeleteOpen,
setConfirmDeleteOpen,
editingTitle,
setEditingTitle,
titleDraft,
setTitleDraft,
pickedEncounterId,
patch,
remove,
commitTitle,
toggleEpisode,
setSuggestedEncounter,
title,
sourceSystemName,
Icon,
inlineSrc,
inlineFailed,
};
}
export type DocumentDetail = ReturnType<typeof useDocumentDetail>;
/**
* The writes from the sheet: the replace-set PATCH (a link written here also
* refreshes the visit's and the dose's own views), and the soft delete whose
* toast carries an Undo; a refused restore names why (`purged`,
* `duplicateExists`).
*/
function useDocumentWrites({
documentId,
onOpenChange,
setMutationError,
setLinkResets,
}: {
documentId: string | null;
onOpenChange: (open: boolean) => void;
setMutationError: (message: string | null) => void;
setLinkResets: (update: (n: number) => number) => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const patch = useMutation({
mutationFn: (input: PatchInput) =>
apiPatch<InboundDocumentDetailDto>(
`/api/documents/inbound/${documentId}`,
input,
),
onSuccess: (_data, input) => {
setMutationError(null);
// A link written from this side changes what the visit's and the
// dose's own views list, so their reads refresh with the vault's.
void invalidateKeys(queryClient, [
queryKeys.documents(),
...(input.encounterIds !== undefined ? [queryKeys.encounters()] : []),
...(input.vaccinationIds !== undefined
? [queryKeys.vaccinations()]
: []),
]);
},
onError: () => {
setMutationError(t("documents.detail.saveError"));
setLinkResets((n) => n + 1);
},
});
const restore = useMutation({
mutationFn: (id: string) =>
apiPost(`/api/documents/inbound/${id}/restore`, {}),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
onError: (error) => {
const reason =
error instanceof ApiError && typeof error.meta?.reason === "string"
? error.meta.reason
: "";
if (reason === "purged") {
showErrorToast(t("documents.error.purged"), { cause: error });
} else if (reason === "duplicateExists") {
showErrorToast(t("documents.error.duplicateExists"), { cause: error });
} else {
showErrorToast(t("documents.toast.restoreFailed"), { cause: error });
}
},
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`),
onSuccess: (_data, id) => {
onOpenChange(false);
void invalidateKeys(queryClient, [queryKeys.documents()]);
toast.success(t("documents.toast.deleted"), {
action: {
label: t("common.undo"),
onClick: () => restore.mutate(id),
},
});
},
onError: () => setMutationError(t("documents.detail.deleteError")),
});
return { patch, remove };
}
File diff suppressed because it is too large. Load diff