mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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
Reference in new issue
Block a user