Files
HealthLog/src/components/documents/document-detail-sheet.tsx
T
Marc-André Bombeck e5de72cae2 feat(documents): hide manual AI actions under auto-read, trim suggestion chrome
When "read documents automatically with AI" is on, the detail sheet no longer
shows the manual per-document action row (Read / Suggest / Summarise) — reading
already happens on upload, so the buttons only nagged. The section header and
the searchable status pill stay, and content search keeps working. With the
setting off the manual controls render exactly as before.

Drop the "review before saving" heading and its grey sub-caption from the
suggestion panel; the reviewable fields and the apply / dismiss controls stay.
Remove the now-unused documents.assist.reviewTitle / reviewHint keys from every
locale.
2026-07-09 17:27:47 +02:00

732 lines
27 KiB
TypeScript

"use client";
/**
* Document detail on `ResponsiveSheet` (bottom sheet on phones, dialog on
* desktop). Inline-class documents (PDF / browser-native images) preview
* inline against the owner-scoped decrypt-and-serve route — skeleton first,
* content on load, internal scroll only (`overscroll-contain`). Attachment-
* class documents get NO fake preview: type glyph, filename, size, and a
* prominent Download button.
*
* Metadata edits save on commit (title on Enter/blur, kind and filing date
* on change, condition links on toggle — replace-set PATCH). Mutation
* errors surface inline (`role="alert"`), never as a QueryErrorCard.
* Delete soft-deletes with an Undo toast (restore endpoint); a restore
* refusal maps `meta.reason` (`purged`, `duplicateExists`) to translated
* copy. A Share action in the footer opens the clinician share-link create
* flow (shared `ShareLinkCreateForm`) with this document pre-attached.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Download, Pencil, Plus, Share2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
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 { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
apiDelete,
ApiError,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentChatPanel } from "./document-chat-panel";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { useIndexDocument } from "./use-content-index";
import {
documentAiErrorKey,
useDocumentAiCapability,
useDocumentsAutoAiRead,
useDocumentSummary,
useSuggestDetails,
} from "./use-document-assist";
import { formatBytes } from "./vault-utils";
type PatchInput = {
title?: string | null;
kind?: InboundDocumentKindValue;
documentDate?: string | null;
episodeIds?: string[];
};
/** Inline preview for Class A documents; skeleton until the blob paints. */
function InlinePreview({
documentId,
mimeType,
title,
}: {
documentId: string;
mimeType: string;
title: string;
}) {
const [loaded, setLoaded] = useState(false);
const src = `/api/documents/inbound/${documentId}/original`;
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">
{!loaded ? (
<Skeleton
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
/>
) : 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>
);
}
export function DocumentDetailSheet({
documentId,
open,
onOpenChange,
assistAvailable,
contentIndexEnabled,
}: {
documentId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Whether the AI "Suggest details" / summary actions can run (from
* `usage.assistAvailable`). When false the AI area shows a calm pointer to
* the AI settings instead of an error — the manual form stays fully usable.
*/
assistAvailable?: boolean;
/** Whether content indexing is available (from `usage.contentIndex.enabled`). */
contentIndexEnabled?: boolean;
}) {
const { t, locale } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const detail = useQuery({
queryKey: queryKeys.inboundDocument(documentId ?? "none"),
enabled: open && documentId !== null,
queryFn: () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${documentId}`),
});
const doc = detail.data;
const episodes = useIllnessEpisodes(true);
// 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.
const capability = useDocumentAiCapability(open && documentId !== null);
// 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(open && documentId !== null);
const suggest = useSuggestDetails();
const summary = useDocumentSummary();
const indexDoc = useIndexDocument();
const [mutationError, setMutationError] = useState<string | null>(null);
const [shareOpen, setShareOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
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 edit + AI 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);
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();
indexDoc.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documentId]);
const patch = useMutation({
mutationFn: (input: PatchInput) =>
apiPatch<InboundDocumentDetailDto>(
`/api/documents/inbound/${documentId}`,
input,
),
onSuccess: () => {
setMutationError(null);
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
onError: () => setMutationError(t("documents.detail.saveError")),
});
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") {
toast.error(t("documents.error.purged"));
} else if (reason === "duplicateExists") {
toast.error(t("documents.error.duplicateExists"));
} else {
toast.error(t("documents.toast.restoreFailed"));
}
},
});
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")),
});
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 });
};
// The affordance gate falls back to the capability probe when the sheet is
// rendered without the usage-derived props (deep link before usage loads).
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
const indexEnabled =
contentIndexEnabled ?? capability.data?.available ?? false;
const autoReadEnabled = autoRead.data?.documentsAutoAiRead ?? false;
const aiMode = capability.data?.mode === "text" ? "text" : "vision";
const aiTarget: DocumentAiTarget | null = doc
? {
documentId: doc.id,
mimeType: doc.mimeType,
filename: doc.filename,
servingClass: doc.servingClass,
}
: null;
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 });
};
const runIndex = () => {
if (!aiTarget) return;
indexDoc.mutate(
{ mode: aiMode, target: aiTarget },
{
onSuccess: () => toast.success(t("documents.ai.readDone")),
onError: (error) => toast.error(t(documentAiErrorKey(error))),
},
);
};
// 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.
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
? format.date(`${suggestion.documentDate}T12:00:00.000Z`)
: null;
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
return (
<>
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
footer={
doc ? (
// Delete sits bottom-LEFT, quieted to a text button: the
// bottom-right slot reads as the primary/confirm action, and a
// destructive control there is a footgun. Share + Download — the
// safe, expected actions — keep the trailing edge. On phones their
// labels collapse to icon-only (aria-labelled) so three controls
// never overflow the sheet at 360 px; the labels return at `sm+`.
<div className="flex w-full items-center justify-between gap-2">
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
<div className="flex items-center gap-2">
<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>
<Button
variant="outline"
asChild
data-slot="document-download"
aria-label={t("documents.detail.download")}
>
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.download")}
</span>
</a>
</Button>
</div>
</div>
) : undefined
}
>
{detail.isPending && open ? (
<div className="space-y-3" data-slot="document-detail-loading">
<Skeleton className="h-48 w-full rounded-lg" />
<Skeleton className="h-9 w-2/3" />
<Skeleton className="h-9 w-1/2" />
</div>
) : null}
{detail.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.detail.loadError")}
</p>
) : null}
{doc ? (
<div className="space-y-6">
{doc.servingClass === "inline" ? (
<InlinePreview
documentId={doc.id}
mimeType={doc.mimeType}
title={title}
/>
) : (
<div className="border-border flex flex-col items-center gap-2 rounded-lg border px-4 py-8 text-center">
{Icon ? (
<Icon className="text-muted-foreground size-8" aria-hidden />
) : null}
<p className="max-w-full truncate text-sm font-medium">
{doc.filename ?? title}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.detail.previewUnavailable")} ·{" "}
{formatBytes(doc.byteSize, locale)}
</p>
<Button asChild size="sm" className="mt-1">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
</div>
)}
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
suggest.isError ? documentAiErrorKey(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
suggestion={suggestion}
suggestionKindLabel={suggestionKindLabel}
suggestionDateLabel={suggestionDateLabel}
appliedFields={appliedFields}
onUseTitle={applyTitle}
onUseKind={applyKind}
onUseDate={applyDate}
onDismissSuggestion={() => setSuggestion(null)}
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
summary.isError ? documentAiErrorKey(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
contentIndexSource={doc.contentIndexSource}
indexPending={indexDoc.isPending}
onIndex={runIndex}
chatSlot={
<DocumentChatPanel
documentId={doc.id}
indexed={doc.hasContentIndex}
/>
}
/>
{mutationError ? (
<p role="alert" className="text-destructive text-sm">
{mutationError}
</p>
) : null}
<div className="space-y-4">
<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")}
/>
) : (
<button
type="button"
id="document-title-input"
onClick={() => {
setTitleDraft(doc.title ?? "");
setEditingTitle(true);
}}
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 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:ring-[3px] 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>
<Select
value={doc.kind}
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"
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}
{(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>
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
size: formatBytes(doc.byteSize, locale),
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
</div>
</div>
) : null}
</ResponsiveSheet>
{doc ? (
<DocumentShareSheet
open={shareOpen}
onOpenChange={setShareOpen}
documentId={doc.id}
documentTitle={title}
/>
) : null}
</>
);
}