mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
The document vault and the admin area each carried a formatBytes; the same backup read 1.25 MB in one and 1.2 MB in the other, and only the vault knew gigabytes. The vault rule (one decimal below 100, up to GB, digits in the reader's locale) now lives in src/lib/format/bytes.ts and takes the locale, so the server-rendered share page uses it too. A test holds the definition to that one module.
1306 lines
51 KiB
TypeScript
1306 lines
51 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 { useCalendarDate } from "@/hooks/use-calendar-date";
|
|
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ArrowRight,
|
|
Check,
|
|
Download,
|
|
ExternalLink,
|
|
FlaskConical,
|
|
Pencil,
|
|
Plus,
|
|
Share2,
|
|
Sparkles,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/errors/error-toast";
|
|
|
|
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ConfirmDialog } from "@/components/ui/confirm-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 { useAiCapability } from "@/hooks/use-ai-capability";
|
|
import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-consent-prompt";
|
|
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
|
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
|
import {
|
|
createFencedBlobLoader,
|
|
useFencedObjectUrl,
|
|
} from "@/hooks/use-fenced-object-url";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
INBOUND_DOCUMENT_KINDS,
|
|
type DocumentSourceSystemValue,
|
|
type DocumentSuggestionDto,
|
|
type DocumentSummaryMode,
|
|
type InboundDocumentDetailDto,
|
|
type InboundDocumentKindValue,
|
|
} from "@/lib/validations/inbound-documents";
|
|
import { DocumentAiSection } from "./document-ai-section";
|
|
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
|
|
import { DocumentRecordLinks } from "./document-record-links";
|
|
import { RelatedRecordsBlock } from "@/components/links/related-records";
|
|
import type { RelatedLink } from "@/lib/links/related";
|
|
import { DocumentFactsSection } from "./document-facts-review";
|
|
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
|
|
import { DocumentSummaryBlock } from "./document-summary-block";
|
|
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 {
|
|
useDocumentAiCapability,
|
|
useDocumentAiErrorText,
|
|
useDocumentsAutoAiRead,
|
|
useDocumentSummary,
|
|
useSuggestDetails,
|
|
} from "./use-document-assist";
|
|
import { formatBytes } from "@/lib/format/bytes";
|
|
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
|
|
|
type PatchInput = {
|
|
title?: string | null;
|
|
kind?: InboundDocumentKindValue;
|
|
documentDate?: string | null;
|
|
episodeIds?: string[];
|
|
encounterIds?: string[];
|
|
vaccinationIds?: string[];
|
|
};
|
|
|
|
/**
|
|
* Download the stored original.
|
|
*
|
|
* v1.37.0 — 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.
|
|
*/
|
|
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.
|
|
*/
|
|
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. */
|
|
function InlinePreview({
|
|
src,
|
|
failed,
|
|
mimeType,
|
|
title,
|
|
}: {
|
|
/**
|
|
* v1.37.0 — 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>
|
|
);
|
|
}
|
|
|
|
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, tCount, locale } = useTranslations();
|
|
const format = useFormatters();
|
|
const calendarDate = useCalendarDate();
|
|
const queryClient = useQueryClient();
|
|
// v1.28.52 (Documents R3) — the vault "Ask the Coach" action opens the REAL
|
|
// fenced coach conversation in the shared side drawer (scoped to this
|
|
// document) instead of a full-page nav; `askCoach(..., doc.id)` carries the
|
|
// document scope through the launch context.
|
|
const launch = useCoachLaunch();
|
|
|
|
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);
|
|
|
|
// 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.
|
|
//
|
|
// v1.36.x — and 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 [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("");
|
|
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();
|
|
storedSummary.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: (_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")),
|
|
});
|
|
|
|
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 });
|
|
};
|
|
|
|
// 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 =
|
|
documentAi.available &&
|
|
(assistAvailable ?? capability.data?.available ?? false);
|
|
const indexEnabled =
|
|
documentAi.available &&
|
|
(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 });
|
|
};
|
|
|
|
// 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 }),
|
|
},
|
|
);
|
|
};
|
|
|
|
// 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
|
|
? calendarDate(suggestion.documentDate)
|
|
: null;
|
|
|
|
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;
|
|
// v1.37.0 — 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 (
|
|
<>
|
|
<ResponsiveSheet
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={title}
|
|
description={t("documents.detail.title")}
|
|
contentWidth="3xl"
|
|
showCloseButton={false}
|
|
headerAction={
|
|
// Enlarged close control rendered in the header's trailing slot —
|
|
// the sheet's own X glyph is `size-4` via the primitive; we hide it
|
|
// (`showCloseButton={false}`) and render this larger `size-5` X with
|
|
// a comfortable min tap target instead. Scoped to this sheet only,
|
|
// so the global primitive default is unchanged for other callers.
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
data-slot="document-detail-close"
|
|
aria-label={t("common.close")}
|
|
onClick={() => onOpenChange(false)}
|
|
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:size-9"
|
|
>
|
|
<X className="size-5" aria-hidden="true" />
|
|
</Button>
|
|
}
|
|
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, open in a new tab and
|
|
// 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">
|
|
{/* v1.36.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">
|
|
{/* v1.36.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 && coach.available && launch ? (
|
|
// v1.28.52 (Documents R3) — "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"
|
|
aria-label={t("documents.chat.openAria")}
|
|
>
|
|
<Sparkles className="size-4" aria-hidden />
|
|
<span className="hidden sm:inline">
|
|
{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>
|
|
) : 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 ? (
|
|
<QueryErrorRow
|
|
inset
|
|
error={detail.error}
|
|
slot="document-detail-error"
|
|
message={t("documents.detail.loadError")}
|
|
onRetry={() => void detail.refetch()}
|
|
/>
|
|
) : null}
|
|
|
|
{doc ? (
|
|
<div className="space-y-6">
|
|
{doc.servingClass === "inline" ? (
|
|
<InlinePreview
|
|
src={inlineSrc}
|
|
failed={inlineFailed}
|
|
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>
|
|
<DocumentDownloadButton
|
|
documentId={doc.id}
|
|
filename={doc.filename}
|
|
readyUrl={null}
|
|
unavailable={false}
|
|
size="sm"
|
|
className="mt-1"
|
|
slot="document-download-inline"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<DocumentSummaryBlock
|
|
summary={doc.summary}
|
|
// The stored summary is read; generating one writes to the
|
|
// owner's record, so a delegate reads what is there and has no
|
|
// generate control.
|
|
canGenerate={canManageDocuments}
|
|
summaryState={doc.summaryState}
|
|
generatedAtLabel={
|
|
doc.summaryGeneratedAt
|
|
? format.date(doc.summaryGeneratedAt)
|
|
: null
|
|
}
|
|
aiEnabled={aiEnabled}
|
|
isGenerating={storedSummary.isPending}
|
|
runPhase={storedSummary.runPhase}
|
|
actionsDisabled={capability.isPending}
|
|
onGenerate={generateStoredSummary}
|
|
/>
|
|
|
|
{canManageDocuments && documentAi.reason === "consent_required" ? (
|
|
<DocumentReadingConsentPrompt />
|
|
) : null}
|
|
|
|
{canManageDocuments && documentAi.available && (
|
|
<DocumentAiSection
|
|
aiEnabled={aiEnabled}
|
|
autoReadEnabled={autoReadEnabled}
|
|
indexEnabled={indexEnabled}
|
|
unavailableReason={capability.data?.reason ?? null}
|
|
actionsDisabled={capability.isPending}
|
|
onSuggest={runSuggest}
|
|
suggestPending={suggest.isPending}
|
|
suggestErrorKey={
|
|
suggest.isError ? aiErrorText(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 ? aiErrorText(summary.error) : null
|
|
}
|
|
onCloseSummary={() => {
|
|
setSummaryOutput(null);
|
|
summary.reset();
|
|
}}
|
|
hasContentIndex={doc.hasContentIndex}
|
|
contentIndexSource={doc.contentIndexSource}
|
|
lastIndexOutcome={doc.lastIndexOutcome}
|
|
indexPending={indexDoc.isPending}
|
|
indexRunPhase={indexDoc.runPhase}
|
|
assistRunPhase={
|
|
suggest.isPending ? suggest.runPhase : summary.runPhase
|
|
}
|
|
onIndex={runIndex}
|
|
/>
|
|
)}
|
|
|
|
{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")}
|
|
/>
|
|
) : !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>
|
|
|
|
<DocumentEncounterSuggestion
|
|
anchor={doc.reportDate ?? doc.documentDate}
|
|
alreadyLinked={
|
|
doc.encounterLinks.length > 0 && pickedEncounterId === null
|
|
}
|
|
disabled={!canManageDocuments}
|
|
value={pickedEncounterId}
|
|
onChange={setSuggestedEncounter}
|
|
/>
|
|
|
|
{/* A vaccination scan offers to file against the dose it records,
|
|
through the same ±7-day rule the visit suggestion uses. */}
|
|
{doc.kind === "VACCINATION" ? (
|
|
<VaccinationDocumentSuggestion
|
|
anchor={doc.reportDate ?? doc.documentDate}
|
|
documentId={doc.id}
|
|
disabled={!canManageDocuments}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Which visits and doses this page is filed against, and — for
|
|
someone who may manage the vault — the links themselves.
|
|
Keyed on the write-failure count so a refused write drops
|
|
the local selection back to what the server confirmed. */}
|
|
<DocumentRecordLinks
|
|
key={`${doc.id}:${linkResets}`}
|
|
doc={doc}
|
|
canManage={canManageDocuments}
|
|
seedFresh={linkSeedFresh}
|
|
seedError={!linkSeedFresh && detail.isRefetchError}
|
|
onRetrySeed={() => void detail.refetch()}
|
|
onChange={(part) => patch.mutate(part)}
|
|
/>
|
|
|
|
{/* v1.43 — the lab results read from this page and the
|
|
medications it belongs to. Conditions, visits and doses
|
|
have their own blocks above. */}
|
|
<RelatedRecordsBlock
|
|
related={(doc as { related?: RelatedLink[] }).related}
|
|
omitKinds={["conditionEpisode", "encounter", "vaccination"]}
|
|
/>
|
|
|
|
{/* Staged-facts review + the stored-text extract recovery. The
|
|
existing extract/confirm chain finally gets its control on
|
|
the document itself: pending facts open the review, a read
|
|
lab document with nothing staged offers "Extract lab
|
|
values" over its stored text — never a re-upload. */}
|
|
<DocumentFactsSection
|
|
doc={doc}
|
|
canManage={canManageDocuments}
|
|
aiEnabled={aiEnabled}
|
|
labsModuleEnabled={labsModuleEnabled}
|
|
/>
|
|
|
|
{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}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</ResponsiveSheet>
|
|
|
|
{doc ? (
|
|
<DocumentShareSheet
|
|
open={shareOpen}
|
|
onOpenChange={setShareOpen}
|
|
documents={[{ id: doc.id, title }]}
|
|
/>
|
|
) : null}
|
|
{doc ? (
|
|
<ConfirmDialog
|
|
open={confirmDeleteOpen}
|
|
onOpenChange={setConfirmDeleteOpen}
|
|
title={t("documents.deleteConfirm.titleOne")}
|
|
body={t("documents.deleteConfirm.body")}
|
|
confirmLabel={t("documents.deleteConfirm.action")}
|
|
onConfirm={() => remove.mutate(doc.id)}
|
|
pending={remove.isPending}
|
|
slot="document-detail-delete"
|
|
destructive
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|