diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx
new file mode 100644
index 000000000..e376ba082
--- /dev/null
+++ b/src/components/documents/document-detail-sheet.tsx
@@ -0,0 +1,463 @@
+"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. No share affordance anywhere — deliberately.
+ */
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Check, Download, Pencil, Plus, Trash2 } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { useIllnessEpisodes } from "@/components/illness/use-illness";
+import { Button } from "@/components/ui/button";
+import { DateField } from "@/components/ui/date-field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ apiDelete,
+ ApiError,
+ apiGet,
+ apiPatch,
+ apiPost,
+} from "@/lib/api/api-fetch";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { invalidateKeys, queryKeys } from "@/lib/query-keys";
+import { cn } from "@/lib/utils";
+import {
+ INBOUND_DOCUMENT_KINDS,
+ type InboundDocumentDetailDto,
+ type InboundDocumentKindValue,
+} from "@/lib/validations/inbound-documents";
+import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
+import { formatBytes } from "./vault-utils";
+
+type PatchInput = {
+ title?: string | null;
+ kind?: InboundDocumentKindValue;
+ documentDate?: string | null;
+ episodeIds?: string[];
+};
+
+/** Inline preview for Class A documents; skeleton until the blob paints. */
+function InlinePreview({
+ documentId,
+ mimeType,
+ title,
+}: {
+ documentId: string;
+ mimeType: string;
+ title: string;
+}) {
+ const [loaded, setLoaded] = useState(false);
+ const src = `/api/documents/inbound/${documentId}/original`;
+ const isPdf = mimeType === "application/pdf";
+
+ return (
+
+ {!loaded ? (
+
+ ) : null}
+ {isPdf ? (
+
+ );
+}
+
+export function DocumentDetailSheet({
+ documentId,
+ open,
+ onOpenChange,
+}: {
+ documentId: string | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { t, locale } = useTranslations();
+ const format = useFormatters();
+ const queryClient = useQueryClient();
+
+ const detail = useQuery({
+ queryKey: queryKeys.inboundDocument(documentId ?? "none"),
+ enabled: open && documentId !== null,
+ queryFn: () =>
+ apiGet(`/api/documents/inbound/${documentId}`),
+ });
+ const doc = detail.data;
+
+ const episodes = useIllnessEpisodes(true);
+
+ const [mutationError, setMutationError] = useState(null);
+ const [editingTitle, setEditingTitle] = useState(false);
+ 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);
+ setMutationError(null);
+ }
+
+ const patch = useMutation({
+ mutationFn: (input: PatchInput) =>
+ apiPatch(
+ `/api/documents/inbound/${documentId}`,
+ input,
+ ),
+ onSuccess: () => {
+ setMutationError(null);
+ void invalidateKeys(queryClient, [queryKeys.documents()]);
+ },
+ onError: () => setMutationError(t("documents.detail.saveError")),
+ });
+
+ const restore = useMutation({
+ mutationFn: (id: string) =>
+ apiPost(`/api/documents/inbound/${id}/restore`, {}),
+ onSuccess: () => {
+ void invalidateKeys(queryClient, [queryKeys.documents()]);
+ },
+ onError: (error) => {
+ const reason =
+ error instanceof ApiError && typeof error.meta?.reason === "string"
+ ? error.meta.reason
+ : "";
+ if (reason === "purged") {
+ toast.error(t("documents.error.purged"));
+ } else if (reason === "duplicateExists") {
+ toast.error(t("documents.error.duplicateExists"));
+ } else {
+ toast.error(t("documents.toast.restoreFailed"));
+ }
+ },
+ });
+
+ const remove = useMutation({
+ mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`),
+ onSuccess: (_data, id) => {
+ onOpenChange(false);
+ void invalidateKeys(queryClient, [queryKeys.documents()]);
+ toast.success(t("documents.toast.deleted"), {
+ action: {
+ label: t("common.undo"),
+ onClick: () => restore.mutate(id),
+ },
+ });
+ },
+ onError: () => setMutationError(t("documents.detail.deleteError")),
+ });
+
+ const commitTitle = () => {
+ if (!doc) return;
+ setEditingTitle(false);
+ const next = titleDraft.trim();
+ const current = doc.title ?? "";
+ if (next === current) return;
+ patch.mutate({ title: next === "" ? null : next });
+ };
+
+ const toggleEpisode = (episodeId: string) => {
+ if (!doc) return;
+ const current = doc.conditionLinks.map((l) => l.episodeId);
+ const next = current.includes(episodeId)
+ ? current.filter((id) => id !== episodeId)
+ : [...current, episodeId];
+ patch.mutate({ episodeIds: next });
+ };
+
+ const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
+ const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
+ const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
+
+ return (
+
+
+
+ >
+ ) : undefined
+ }
+ >
+ {detail.isPending && open ? (
+
+
+
+
+
+ ) : null}
+
+ {detail.isError ? (
+
+ {t("documents.detail.loadError")}
+
+ ) : null}
+
+ {doc ? (
+
+ {doc.servingClass === "inline" ? (
+
+ ) : (
+
+ {Icon ? (
+
+ ) : null}
+
+ {doc.filename ?? title}
+
+
+ {t("documents.detail.previewUnavailable")} ·{" "}
+ {formatBytes(doc.byteSize, locale)}
+
+
+
+ )}
+
+ {mutationError ? (
+
+ {mutationError}
+
+ ) : null}
+
+
+
+
+ {editingTitle ? (
+
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")}
+ />
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ patch.mutate({ documentDate: value === "" ? null : value })
+ }
+ aria-label={t("documents.detail.dateLabel")}
+ />
+
+
+
+
+
+ {t("documents.detail.conditionsLabel")}
+
+
+ {doc.conditionLinks.map((link) => (
+
+
{link.name}
+
+ ))}
+ {doc.conditionLinks.length === 0 ? (
+
+ {t("documents.detail.noConditions")}
+
+ ) : null}
+ {(episodes.data?.length ?? 0) > 0 ? (
+
+
+
+
+
+
+ {episodes.data?.map((episode) => {
+ const linked = doc.conditionLinks.some(
+ (l) => l.episodeId === episode.id,
+ );
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+ ) : null}
+
+
+
+
+ {t("documents.detail.uploadedMeta", {
+ date: format.date(doc.createdAt),
+ size: formatBytes(doc.byteSize, locale),
+ })}
+ {doc.filename ? ` · ${doc.filename}` : ""}
+
+
+
+ ) : null}
+
+ );
+}