Merge branch 'feat/vault-w4-qa' into release/v1.27.17

This commit is contained in:
Marc-André Bombeck committed 2026-07-07 15:10:45 +02:00
commit 03058b0210
81 files changed
+10544 -3246

No files matched your search

+8
View File
@@ -28,6 +28,9 @@ export interface AdminUser {
createdAt: string;
// v1.23 — per-user "require a second factor" override.
mfaEnforced: boolean;
// Document vault — per-user storage-quota override in bytes; null means
// the instance-wide default applies.
documentQuotaBytes: number | null;
passkeyCount: number;
}
@@ -88,6 +91,11 @@ export interface AdminSettings {
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
defaultUserTimezone: string | null;
moodLogGlobal?: boolean;
// Document vault — the two admin-tunable limits (bytes on the wire; the
// UI edits them as MB / GB). The per-file cap clamps to the hard 100 MiB
// ceiling server-side.
documentMaxFileBytes: number;
documentQuotaBytes: number;
}
export interface AdminAuditEntry {
@@ -1,14 +1,29 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Settings } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
import { listSupportedTimezones } from "@/lib/tz/format";
// Document-vault limits: bytes on the wire, MB / GB in the UI. The bounds
// mirror the server-side schema (per-file cap hard-clamped to 100 MiB —
// single-shot GCM + bounded in-memory reads are load-bearing past that).
const MIB = 1_048_576;
const GIB = 1_073_741_824;
const MAX_FILE_MB_MIN = 1;
const MAX_FILE_MB_MAX = 100;
const QUOTA_GB_MIN = 0.1;
const QUOTA_GB_MAX = 1024;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. The admin copy previously diverged on the
@@ -26,6 +41,43 @@ export function GeneralSettingsSection() {
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
// Draft-while-editing pattern for the two limit inputs: null = not
// editing (show the stored value), string = the in-progress draft.
// Commit clamps to the schema bounds and converts to bytes.
const [maxFileDraft, setMaxFileDraft] = useState<string | null>(null);
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
const storedMaxFileMb =
settings?.documentMaxFileBytes != null
? String(Math.round(settings.documentMaxFileBytes / MIB))
: "";
const storedQuotaGb =
settings?.documentQuotaBytes != null
? String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10)
: "";
const commitMaxFile = () => {
if (maxFileDraft === null) return;
const parsed = Number(maxFileDraft);
setMaxFileDraft(null);
if (!Number.isFinite(parsed) || maxFileDraft.trim() === "") return;
const mb = clamp(Math.round(parsed), MAX_FILE_MB_MIN, MAX_FILE_MB_MAX);
const bytes = mb * MIB;
if (bytes === settings?.documentMaxFileBytes) return;
updateSettings.mutate({ documentMaxFileBytes: bytes });
};
const commitQuota = () => {
if (quotaDraft === null) return;
const parsed = Number(quotaDraft);
setQuotaDraft(null);
if (!Number.isFinite(parsed) || quotaDraft.trim() === "") return;
const gb = clamp(parsed, QUOTA_GB_MIN, QUOTA_GB_MAX);
const bytes = Math.round(gb * GIB);
if (bytes === settings?.documentQuotaBytes) return;
updateSettings.mutate({ documentQuotaBytes: bytes });
};
return (
<SettingsCard>
<SettingsCardHeader
@@ -117,6 +169,65 @@ export function GeneralSettingsSection() {
))}
</NativeSelect>
</div>
{/* Document vault — the two admin-tunable limits (per-file cap +
per-user storage quota). Everything else about the vault is
committed behaviour; these are deliberately the only knobs. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">{t("admin.documentMaxFile")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.documentMaxFileDescription")}
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<Input
type="number"
inputMode="numeric"
min={MAX_FILE_MB_MIN}
max={MAX_FILE_MB_MAX}
step={1}
className="w-24 text-right tabular-nums"
aria-label={t("admin.documentMaxFile")}
value={maxFileDraft ?? storedMaxFileMb}
onChange={(e) => setMaxFileDraft(e.target.value)}
onBlur={commitMaxFile}
onKeyDown={(e) => {
if (e.key === "Enter") commitMaxFile();
}}
disabled={updateSettings.isPending || settings == null}
/>
<span className="text-muted-foreground text-xs">MB</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">{t("admin.documentQuota")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.documentQuotaDescription")}
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<Input
type="number"
inputMode="decimal"
min={QUOTA_GB_MIN}
max={QUOTA_GB_MAX}
step={0.1}
className="w-24 text-right tabular-nums"
aria-label={t("admin.documentQuota")}
value={quotaDraft ?? storedQuotaGb}
onChange={(e) => setQuotaDraft(e.target.value)}
onBlur={commitQuota}
onKeyDown={(e) => {
if (e.key === "Enter") commitQuota();
}}
disabled={updateSettings.isPending || settings == null}
/>
<span className="text-muted-foreground text-xs">GB</span>
</div>
</div>
</div>
</SettingsCard>
);
@@ -55,6 +55,9 @@ export function UserManagementSection() {
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [editUsername, setEditUsername] = useState("");
const [editEmail, setEditEmail] = useState("");
// Document vault — per-user storage-quota override, edited in GB.
// Empty string = no override (the instance default applies).
const [editQuotaGb, setEditQuotaGb] = useState("");
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [resetMsg, setResetMsg] = useState<string | null>(null);
@@ -142,6 +145,11 @@ export function UserManagementSection() {
setEditingUser(u);
setEditUsername(u.username);
setEditEmail(u.email ?? "");
setEditQuotaGb(
u.documentQuotaBytes === null
? ""
: String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
);
}
function startReset(u: AdminUser) {
@@ -448,11 +456,22 @@ export function UserManagementSection() {
onSubmit={(e) => {
e.preventDefault();
if (updateUser.isPending) return;
const quotaTrimmed = editQuotaGb.trim();
const quotaParsed = Number(quotaTrimmed);
updateUser.mutate({
id: editingUser.id,
data: {
username: editUsername,
email: editEmail || null,
// Empty clears the override; a value clamps to the same
// bounds the server schema enforces (0.1–1024 GB).
documentQuotaBytes:
quotaTrimmed === "" || !Number.isFinite(quotaParsed)
? null
: Math.round(
Math.min(1024, Math.max(0.1, quotaParsed)) *
1_073_741_824,
),
},
});
}}
@@ -481,6 +500,32 @@ export function UserManagementSection() {
data-1p-ignore="true"
/>
</div>
<div className="space-y-1">
<Label htmlFor="edit-document-quota">
{t("admin.userDocumentQuota")}
</Label>
<div className="flex items-center gap-2">
<Input
id="edit-document-quota"
type="number"
inputMode="decimal"
min={0.1}
max={1024}
step={0.1}
className="w-28 text-right tabular-nums"
value={editQuotaGb}
onChange={(e) => setEditQuotaGb(e.target.value)}
placeholder={t("admin.userDocumentQuotaDefault")}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
/>
<span className="text-muted-foreground text-xs">GB</span>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.userDocumentQuotaHint")}
</p>
</div>
<div className="flex gap-2">
<Button
type="submit"
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The floating bulk bar, pinned via single-pass static renders: a labelled
* toolbar carrying the selected count and the four bulk verbs. The
* link-condition menu only renders when the account actually has episodes
* to link — no dead affordance on an episode-free account.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentBulkBar } from "../document-bulk-bar";
function render(episodes: { id: string; label: string }[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentBulkBar
selectedCount={3}
episodes={episodes}
busy={false}
onSetKind={() => {}}
onLinkEpisode={() => {}}
onDelete={() => {}}
onClear={() => {}}
/>
</I18nProvider>,
);
}
describe("<DocumentBulkBar>", () => {
it("renders a labelled toolbar with count and the bulk verbs", () => {
const html = render([{ id: "ep1", label: "Knee" }]);
expect(html).toContain('data-slot="document-bulk-bar"');
expect(html).toContain('role="toolbar"');
expect(html).toContain("3 selected");
expect(html).toContain("Change type");
expect(html).toContain("Link condition");
expect(html).toContain("Delete");
expect(html).toContain("Clear selection");
});
it("omits the link-condition verb when the account has no episodes", () => {
const html = render([]);
expect(html).not.toContain("Link condition");
expect(html).toContain("Change type");
});
});
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentCard, UploadStateCard } from "../document-card";
import type { UploadQueueItem } from "../use-document-upload";
/**
* The vault card's render contract: title falls back filename → "untitled",
* the meta line stays muted, the attachment-class badge appears ONLY for
* download-only formats, condition links paint as pills, and the two
* transient upload cards (in-flight ring / translated failure) render from
* the same footprint.
*/
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
return {
id: "doc-1",
kind: "IMAGING",
title: "MRT Knie",
filename: "mrt-knie.pdf",
mimeType: "application/pdf",
byteSize: 2_500_000,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2025-10-04",
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
servingClass: "inline",
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
...overrides,
};
}
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const noop = () => {};
describe("<DocumentCard>", () => {
it("renders title, condition pill, and NO attachment badge for inline docs", () => {
const html = render(
<DocumentCard
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain("MRT Knie");
expect(html).toContain("Knie");
expect(html).toContain('data-document-id="doc-1"');
expect(html).not.toContain("Download only");
});
it("shows the download-only badge for attachment-class documents", () => {
const html = render(
<DocumentCard
document={doc({
servingClass: "attachment",
mimeType: "application/octet-stream",
filename: "befund.docx",
title: null,
})}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain("Download only");
// Title falls back to the filename.
expect(html).toContain("befund.docx");
});
it("falls back to the untitled label and rings when highlighted", () => {
const html = render(
<DocumentCard
document={doc({ title: null, filename: null })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted
/>,
);
expect(html).toContain("Untitled document");
expect(html).toContain("ring-2");
});
});
describe("<UploadStateCard>", () => {
const base: UploadQueueItem = {
localId: "u1",
fileName: "scan.jpg",
byteSize: 4_000_000,
status: "uploading",
progress: 0.4,
};
it("paints the progress ring while uploading", () => {
const html = render(<UploadStateCard item={base} onDismiss={noop} />);
expect(html).toContain('role="progressbar"');
expect(html).toContain('aria-valuenow="40"');
expect(html).toContain("scan.jpg");
});
it("renders the translated over-limit reason with the configured cap", () => {
const html = render(
<UploadStateCard
item={{
...base,
status: "error",
failure: {
ok: false,
reason: "fileTooLarge",
maxFileBytes: 26_214_400,
},
}}
onDismiss={noop}
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain("25 MB");
expect(html).not.toContain('role="progressbar"');
});
it("renders the quota reason with used/quota figures", () => {
const html = render(
<UploadStateCard
item={{
...base,
status: "error",
failure: {
ok: false,
reason: "quotaExceeded",
quotaBytes: 1_073_741_824,
usedBytes: 1_020_054_732,
},
}}
onDismiss={noop}
/>,
);
expect(html).toContain("Storage is full");
expect(html).toContain("1 GB");
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* Static-render pins for the timeline's accessibility contract: the
* virtualized container is a labelled list, and the transient upload queue
* is a polite live region. (The windowed rows themselves only mount
* client-side once the virtualizer measures the scroll container — the
* keyboard path is covered by the e2e battery.)
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentTimeline } from "../document-timeline";
import type { UploadQueueItem } from "../use-document-upload";
function render(uploadItems: UploadQueueItem[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentTimeline
documents={[]}
uploadItems={uploadItems}
onDismissUpload={() => {}}
hasNextPage={false}
isFetchingNextPage={false}
onLoadMore={() => {}}
selectedIds={new Set()}
onToggleSelected={() => {}}
onOpen={() => {}}
highlightId={null}
/>
</I18nProvider>,
);
}
describe("<DocumentTimeline> semantics", () => {
it("renders the virtualized container as a labelled list", () => {
const html = render([]);
expect(html).toContain('role="list"');
expect(html).toContain('aria-label="Documents timeline"');
});
it("announces the upload queue through a polite live region", () => {
const html = render([
{
localId: "l1",
fileName: "scan.pdf",
byteSize: 1024,
status: "uploading",
progress: 0.4,
},
]);
expect(html).toContain('aria-live="polite"');
expect(html).toContain("scan.pdf");
});
});
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The vault page's designed states, pinned via single-pass static renders:
*
* - first load paints the skeleton grid (`documents-loading`) — a shape
* that matches the loaded card grid, never a bare spinner;
* - an empty unfiltered corpus paints the TEACHING empty state (what
* belongs here + an upload affordance), not the error copy;
* - an empty FILTERED view paints the no-matches state with a
* clear-filters affordance — a distinct state, per the standards a
* failed query may never fall through to either.
*/
let mockSearch = "";
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
usePathname: () => "/documents",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: { inboundDocuments: true } },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
// Never resolves within the synchronous SSR render → queries whose
// cache is not seeded stay pending.
apiGet: () => new Promise(() => {}),
apiPatch: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { DocumentsView } from "../documents-view";
function render(search: string, seedList?: { kinds?: never } | object) {
mockSearch = search;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (seedList !== undefined) {
queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), {
pages: [{ documents: [], nextCursor: null }],
pageParams: [null],
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<DocumentsView />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<DocumentsView> states", () => {
it("first load: skeleton grid matching the card layout, no spinner", () => {
const html = render("");
expect(html).toContain('data-slot="documents-loading"');
expect(html).toContain('data-slot="skeleton"');
expect(html).toContain('data-slot="document-filter-bar"');
expect(html).toContain('data-slot="document-upload-zone"');
// A pending list must never read as an empty vault.
expect(html).not.toContain("Your document vault is empty");
});
it("empty unfiltered corpus: the teaching empty state with an upload CTA", () => {
const html = render("", {});
expect(html).toContain('data-slot="empty-state"');
expect(html).toContain("Your document vault is empty");
expect(html).toContain("Upload a first document");
expect(html).not.toContain("No matching documents");
});
it("empty filtered view: the distinct no-matches state with clear-filters", () => {
const html = render("kind=IMAGING", { kinds: ["IMAGING"] });
expect(html).toContain("No matching documents");
expect(html).toContain("Clear filters");
expect(html).not.toContain("Your document vault is empty");
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The illness page's "Dokumente" section, pinned via static renders:
* module-gated (renders NOTHING when the account has not opted in), a
* quiet one-line affordance on an episode without documents (never a
* teaching empty state), and compact deep-linking rows when links exist.
*/
let moduleEnabled = true;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: { inboundDocuments: moduleEnabled } },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { EpisodeDocumentsCard } from "../episode-documents-card";
function doc(id: string, title: string): InboundDocumentDto {
return {
id,
kind: "IMAGING",
title,
filename: `${id}.pdf`,
mimeType: "application/pdf",
byteSize: 2048,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2025-10-04",
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep1", name: "Knee" }],
servingClass: "inline",
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
};
}
function render(seed?: InboundDocumentDto[]) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (seed !== undefined) {
queryClient.setQueryData(queryKeys.inboundDocumentEpisodePreview("ep1"), {
documents: seed,
nextCursor: null,
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<EpisodeDocumentsCard episodeId="ep1" />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<EpisodeDocumentsCard>", () => {
it("renders nothing when the documents module is off", () => {
moduleEnabled = false;
expect(render([])).toBe("");
moduleEnabled = true;
});
it("shows a quiet one-line affordance on an episode without documents", () => {
const html = render([]);
expect(html).toContain('data-slot="episode-documents-card"');
expect(html).toContain("No documents linked to this condition yet.");
expect(html).toContain("Link");
expect(html).toContain("Upload");
// Not the vault's teaching empty state.
expect(html).not.toContain('data-slot="empty-state"');
});
it("lists linked documents as rows deep-linking into the vault", () => {
const html = render([doc("d1", "MRT Knie"), doc("d2", "Röntgen")]);
expect(html).toContain("MRT Knie");
expect(html).toContain("Röntgen");
expect(html).toContain("/documents?episode=ep1&amp;doc=d1");
// The pre-linked upload deep link carries the episode filter.
expect(html).toContain('href="/documents?episode=ep1"');
});
});
@@ -1,373 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type {
ExtractedFactDto,
InboundDocumentDetailDto,
InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
/**
* v1.25 — the Documents library.
*
* The harness is SSR-only (node env, no jsdom): we seed the TanStack cache and
* assert the static markup. Interaction-driven paths (the exact request params,
* the provider-unsupported classification) are covered by the pure-helper unit
* tests in `library-utils.test.ts`; here we assert the rendered library, the
* detail/review surface, the edit form, and the inline provider note.
*/
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
apiFetch: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import {
AlreadyConfirmedNote,
DocumentDetail,
DocumentMetaEditForm,
InboundDocumentsView,
ProviderUnsupportedNote,
} from "../inbound-documents-view";
function doc(
id: string,
over: Partial<InboundDocumentDto> = {},
): InboundDocumentDto {
return {
id,
kind: "LAB_RESULT",
title: null,
filename: `${id}.pdf`,
mimeType: "application/pdf",
byteSize: 1000,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2026-06-20",
errorReason: null,
factCount: 0,
pendingCount: 0,
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T08:00:00.000Z",
...over,
};
}
function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
seed?.(queryClient);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InboundDocumentsView> library", () => {
it("renders the grouped list with titles, badges, and load-more", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{
pages: [
{
documents: [
doc("d1", {
title: "Cardiology report",
documentDate: "2026-06-20",
pendingCount: 2,
factCount: 3,
}),
doc("d2", {
title: "Blood panel",
documentDate: "2026-06-20",
}),
],
nextCursor: "cursor-next",
},
],
pageParams: [null],
},
);
});
// Library + toolbar shells render.
expect(html).toContain('data-slot="documents-library"');
expect(html).toContain('data-slot="documents-toolbar"');
// Date group header + rows.
expect(html).toContain('data-slot="documents-date-group"');
expect(html).toContain('data-slot="documents-row"');
// Titles surface (title preferred over filename).
expect(html).toContain("Cardiology report");
expect(html).toContain("Blood panel");
// Kind + status badges from the EN bundle.
expect(html).toContain("Lab result");
expect(html).toContain("Stored");
// pendingCount badge.
expect(html).toContain("2 to review");
// nextCursor → load-more button.
expect(html).toContain("Load more");
// A11Y-M2: the row's aria-controls points at the detail region id.
expect(html).toContain('aria-controls="documents-detail-d1"');
// QoL: accepted formats + size ceiling are shown by the file field.
expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB");
});
it("disables the upload button and shows a hint until a file is chosen", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
);
});
// A11Y-M4: with no file staged, an inline hint explains why upload is
// disabled (the hint only renders on the !hasFile / not-pending path).
expect(html).toContain("Choose a file to upload.");
});
it("renders the document-date range filter inputs", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
);
});
// PERF-L: from/to date inputs are wired into the toolbar.
expect(html).toContain('id="doc-from"');
expect(html).toContain('id="doc-to"');
});
it("shows the empty state when there are no documents", () => {
const html = render(<InboundDocumentsView />, (qc) => {
qc.setQueryData(
queryKeys.inboundDocumentList({
q: undefined,
kind: undefined,
sort: "documentDate",
order: "desc",
}),
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
);
});
expect(html).toContain("No documents yet.");
expect(html).not.toContain('data-slot="documents-row"');
});
});
function fact(id: string): ExtractedFactDto {
return {
id,
factType: "OBSERVATION",
status: "PENDING",
confidence: 0.9,
needsReview: false,
data: {
label: "LDL Cholesterol",
code: null,
codeSystem: null,
value: 95,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2026-06-20",
},
provenance: { sourceText: "LDL 95 mg/dL", page: 0, confidence: 0.9 },
committedRecordId: null,
committedRecordType: null,
};
}
describe("<DocumentDetail>", () => {
function detail(over: Partial<InboundDocumentDetailDto> = {}) {
return {
...doc("d1", { title: "Cardiology report", status: "EXTRACTED" }),
facts: [fact("f1")],
...over,
} as InboundDocumentDetailDto;
}
it("renders the original link, edit + extract controls, and the review facts", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
},
);
expect(html).toContain('data-slot="documents-detail"');
// Original view link points at the raw-bytes route.
expect(html).toContain("/api/documents/inbound/d1/original");
expect(html).toContain('data-slot="documents-edit-trigger"');
// Extract button is present (status is not CONFIRMED).
expect(html).toContain('data-slot="documents-extract"');
expect(html).toContain("Extract facts");
// The staged fact's transcription summary renders for review.
expect(html).toContain("LDL Cholesterol");
expect(html).toContain("Save approved facts");
});
it("hides the extract button once the document is confirmed", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ status: "CONFIRMED", facts: [] }),
);
},
);
expect(html).not.toContain('data-slot="documents-extract"');
});
it("hides the extract button once any fact is approved (PERF-H1 gate)", () => {
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }),
);
},
);
expect(html).not.toContain('data-slot="documents-extract"');
});
it("threads the region id onto the detail card for aria-controls", () => {
const html = render(
<DocumentDetail
documentId="d1"
regionId="documents-detail-d1"
onClosed={() => {}}
/>,
(qc) => {
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
},
);
expect(html).toContain('id="documents-detail-d1"');
});
it("falls back to the kind label when title and filename are absent", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ title: null, filename: null, kind: "IMAGING" }),
);
},
);
expect(html).toContain("Imaging");
});
it("shows the committed facts summary once approved, not the empty note", () => {
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }),
);
},
);
// M6: provenance stays visible — the approved facts are listed read-only…
expect(html).toContain('data-slot="documents-committed"');
expect(html).toContain("Approved from this document");
expect(html).toContain("LDL Cholesterol");
// …instead of stranding the user on "Nothing left to review."
expect(html).not.toContain("Nothing left to review.");
});
it("explains why a low-confidence fact can't be approved yet", () => {
const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true };
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(
queryKeys.inboundDocument("d1"),
detail({ facts: [lowConf] }),
);
},
);
// M7: the disabled Approve is tied to a plain-language reason.
expect(html).toContain("Edit this fact to approve it.");
expect(html).toContain("Needs review");
});
it("offers a confirm step before discarding", () => {
const html = render(
<DocumentDetail documentId="d1" onClosed={() => {}} />,
(qc) => {
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
},
);
// A11Y-M3: discard is gated behind an AlertDialog trigger, not one click.
expect(html).toContain('data-slot="documents-discard-trigger"');
});
});
describe("<DocumentMetaEditForm>", () => {
it("renders the metadata fields seeded from the document", () => {
const html = render(
<DocumentMetaEditForm
doc={doc("d1", { title: "Old title", documentDate: "2026-06-20" })}
onSaved={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain('data-slot="documents-edit-form"');
expect(html).toContain('value="Old title"');
expect(html).toContain('value="2026-06-20"');
expect(html).toContain("Save");
});
});
describe("<ProviderUnsupportedNote>", () => {
it("renders the calm inline note as a polite live region (A11Y-H1)", () => {
const html = render(<ProviderUnsupportedNote />);
expect(html).toContain('data-slot="documents-provider-note"');
expect(html).toContain("Configure a document-scan provider");
expect(html).toContain('role="status"');
expect(html).toContain('aria-live="polite"');
});
});
describe("<AlreadyConfirmedNote>", () => {
it("renders a calm polite live region when extraction is refused", () => {
const html = render(<AlreadyConfirmedNote />);
expect(html).toContain('data-slot="documents-already-confirmed-note"');
expect(html).toContain('role="status"');
expect(html).toContain('aria-live="polite"');
expect(html).toContain("already been reviewed");
});
});
@@ -1,283 +0,0 @@
import { describe, it, expect } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import enMessages from "../../../../messages/en.json";
import {
buildDocumentListSearch,
classifyUploadError,
commitFailureReasonKey,
confirmFailureReasonKey,
documentDateKey,
formatDateGroupLabel,
groupDocumentsByDate,
isAlreadyConfirmedError,
isLocalOcrDisabledError,
isProviderUnsupportedError,
} from "../library-utils";
/** Resolve a dotted i18n key against a bundle (the dynamic reason keys aren't
* literal `t()` call sites, so the call-site coverage guard can't see them —
* assert here that each one actually resolves to a string). */
function resolveKey(bundle: unknown, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(acc, part) =>
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[part]
: undefined,
bundle,
);
}
function doc(
id: string,
over: Partial<InboundDocumentDto> = {},
): InboundDocumentDto {
return {
id,
kind: "OTHER",
title: null,
filename: `${id}.pdf`,
mimeType: "application/pdf",
byteSize: 1000,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: null,
errorReason: null,
factCount: 0,
pendingCount: 0,
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T08:00:00.000Z",
...over,
};
}
describe("buildDocumentListSearch", () => {
it("emits the active filters, sort, order, and limit", () => {
const qs = buildDocumentListSearch(
{
q: "cardio",
kind: "LAB_RESULT",
from: "2026-01-01",
to: "2026-06-01",
sort: "title",
order: "asc",
},
null,
25,
);
const sp = new URLSearchParams(qs);
expect(sp.get("q")).toBe("cardio");
expect(sp.get("kind")).toBe("LAB_RESULT");
expect(sp.get("from")).toBe("2026-01-01");
expect(sp.get("to")).toBe("2026-06-01");
expect(sp.get("sort")).toBe("title");
expect(sp.get("order")).toBe("asc");
expect(sp.get("limit")).toBe("25");
expect(sp.has("cursor")).toBe(false);
});
it("omits empty filters and appends the keyset cursor when present", () => {
const qs = buildDocumentListSearch(
{ sort: "documentDate", order: "desc" },
"cursor-123",
);
const sp = new URLSearchParams(qs);
expect(sp.has("q")).toBe(false);
expect(sp.has("kind")).toBe(false);
expect(sp.get("sort")).toBe("documentDate");
expect(sp.get("order")).toBe("desc");
expect(sp.get("cursor")).toBe("cursor-123");
});
});
describe("documentDateKey", () => {
it("prefers the user filing date", () => {
expect(documentDateKey(doc("a", { documentDate: "2026-03-15" }))).toBe(
"2026-03-15",
);
});
it("falls back to the upload day from createdAt", () => {
expect(
documentDateKey(doc("a", { createdAt: "2026-06-20T08:00:00.000Z" })),
).toBe("2026-06-20");
});
});
describe("groupDocumentsByDate", () => {
it("buckets consecutive same-date documents, preserving server order", () => {
const groups = groupDocumentsByDate([
doc("a", { documentDate: "2026-06-20" }),
doc("b", { documentDate: "2026-06-20" }),
doc("c", { documentDate: "2026-06-19" }),
]);
expect(groups.map((g) => g.key)).toEqual(["2026-06-20", "2026-06-19"]);
expect(groups[0].documents.map((d) => d.id)).toEqual(["a", "b"]);
expect(groups[1].documents.map((d) => d.id)).toEqual(["c"]);
});
it("returns an empty array for no documents", () => {
expect(groupDocumentsByDate([])).toEqual([]);
});
});
describe("isProviderUnsupportedError", () => {
it("matches the extract 422 provider-unsupported signal", () => {
const err = new ApiError("no provider", 422, {
errorCode: "documents.inbound.providerUnsupported",
});
expect(isProviderUnsupportedError(err)).toBe(true);
});
it("rejects other API errors and non-errors", () => {
expect(
isProviderUnsupportedError(
new ApiError("rate", 429, {
errorCode: "documents.inbound.rateLimited",
}),
),
).toBe(false);
expect(isProviderUnsupportedError(new Error("boom"))).toBe(false);
expect(isProviderUnsupportedError(null)).toBe(false);
});
});
describe("isAlreadyConfirmedError", () => {
it("matches the extract 409 partly-confirmed signal", () => {
const err = new ApiError("already confirmed", 409, {
errorCode: "documents.inbound.alreadyPartlyConfirmed",
});
expect(isAlreadyConfirmedError(err)).toBe(true);
});
it("matches the extract 422 fully-confirmed signal", () => {
const err = new ApiError("already confirmed", 422, {
errorCode: "documents.inbound.alreadyConfirmed",
});
expect(isAlreadyConfirmedError(err)).toBe(true);
});
it("rejects other API errors and non-errors", () => {
expect(
isAlreadyConfirmedError(
new ApiError("no provider", 422, {
errorCode: "documents.inbound.providerUnsupported",
}),
),
).toBe(false);
expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false);
expect(isAlreadyConfirmedError(null)).toBe(false);
});
});
describe("classifyUploadError", () => {
it("maps each upload error code to its kind", () => {
const code = (c: string) =>
classifyUploadError(new ApiError("x", 400, { errorCode: c }));
expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge");
expect(code("documents.inbound.fileType")).toBe("fileType");
expect(code("documents.inbound.rateLimited")).toBe("rateLimited");
expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata");
});
it("falls back to the HTTP status when the body carries no error code", () => {
expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge");
expect(classifyUploadError(new ApiError("wrong type", 415))).toBe(
"fileType",
);
expect(classifyUploadError(new ApiError("slow down", 429))).toBe(
"rateLimited",
);
});
it("returns generic for unknown errors and non-API errors", () => {
expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic");
expect(classifyUploadError(new Error("network"))).toBe("generic");
expect(classifyUploadError(null)).toBe("generic");
});
});
describe("isLocalOcrDisabledError", () => {
it("matches the extract 422 local-OCR-disabled signal", () => {
const err = new ApiError("local ocr off", 422, {
errorCode: "documents.inbound.localOcrDisabled",
});
expect(isLocalOcrDisabledError(err)).toBe(true);
});
it("rejects other API errors and non-errors", () => {
expect(
isLocalOcrDisabledError(
new ApiError("no provider", 422, {
errorCode: "documents.inbound.providerUnsupported",
}),
),
).toBe(false);
expect(isLocalOcrDisabledError(new Error("boom"))).toBe(false);
expect(isLocalOcrDisabledError(null)).toBe(false);
});
});
describe("commitFailureReasonKey", () => {
it("maps each commit-failure code to a reason key that resolves", () => {
const cases: Record<string, string> = {
"observation.unitMismatch": "documents.review.commitError.unitMismatch",
"observation.unitRequired": "documents.review.commitError.unitRequired",
"something.unknown": "documents.review.commitError.generic",
};
for (const [code, key] of Object.entries(cases)) {
expect(commitFailureReasonKey(code)).toBe(key);
// Every reason the toast can surface resolves to a real string.
expect(typeof resolveKey(enMessages, key)).toBe("string");
}
});
});
describe("confirmFailureReasonKey", () => {
it("surfaces the shared reason when every failed fact agrees", () => {
const key = confirmFailureReasonKey([
{ reason: "observation.unitMismatch" },
{ reason: "observation.unitMismatch" },
]);
expect(key).toBe("documents.review.commitError.unitMismatch");
expect(typeof resolveKey(enMessages, key)).toBe("string");
});
it("falls back to the generic reason when failures disagree", () => {
const key = confirmFailureReasonKey([
{ reason: "observation.unitMismatch" },
{ reason: "observation.unitRequired" },
]);
expect(key).toBe("documents.review.commitError.generic");
});
it("uses the single failure's reason for a one-item batch", () => {
expect(
confirmFailureReasonKey([{ reason: "observation.unitRequired" }]),
).toBe("documents.review.commitError.unitRequired");
});
});
describe("formatDateGroupLabel", () => {
it("delegates the calendar day to the supplied app formatter", () => {
const seen: string[] = [];
const formatDate = (value: string) => {
seen.push(value);
return "FORMATTED";
};
expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED");
// Handed over as a noon-UTC instant so the day never shifts under the tz.
expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]);
});
it("returns a malformed key unchanged without calling the formatter", () => {
const formatDate = () => {
throw new Error("should not be called");
};
expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date");
});
});
@@ -0,0 +1,283 @@
import { describe, expect, it } from "vitest";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildTimelineItems,
buildVaultListApiSearch,
classifyUploadFailure,
countActiveFilters,
documentDateKey,
expandRangeSelection,
formatBytes,
formatMonthLabel,
parseUploadResponse,
parseVaultSearchParams,
vaultFiltersToSearch,
} from "../vault-utils";
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
return {
id: "doc-1",
kind: "DOCTOR_REPORT",
title: null,
filename: "letter.pdf",
mimeType: "application/pdf",
byteSize: 1024,
status: "STORED",
providerType: null,
reportDate: null,
documentDate: "2026-03-10",
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [],
servingClass: "inline",
createdAt: "2026-03-11T08:00:00.000Z",
updatedAt: "2026-03-11T08:00:00.000Z",
...overrides,
};
}
describe("URL facet round-trip", () => {
it("parses q / kind / episode / year and serialises back identically", () => {
const search = "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025";
const filters = parseVaultSearchParams(new URLSearchParams(search));
expect(filters).toEqual({
q: "MRT",
kinds: ["IMAGING", "LAB_RESULT"],
episodeId: "ep1",
year: 2025,
});
expect(vaultFiltersToSearch(filters)).toBe(
"q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025",
);
});
it("accepts repeated kind params and drops unknown kinds", () => {
const sp = new URLSearchParams();
sp.append("kind", "IMAGING");
sp.append("kind", "NOT_A_KIND");
sp.append("kind", "IMAGING"); // duplicate collapses
const filters = parseVaultSearchParams(sp);
expect(filters.kinds).toEqual(["IMAGING"]);
});
it("normalises kind order so the cache key is stable", () => {
const a = parseVaultSearchParams(
new URLSearchParams("kind=LAB_RESULT,IMAGING"),
);
const b = parseVaultSearchParams(
new URLSearchParams("kind=IMAGING,LAB_RESULT"),
);
expect(a).toEqual(b);
});
it("ignores a malformed year and returns the empty default view", () => {
expect(parseVaultSearchParams(new URLSearchParams("year=20x5"))).toEqual(
{},
);
expect(vaultFiltersToSearch({})).toBe("");
});
it("counts active facets per chip (search counts once)", () => {
expect(countActiveFilters({})).toBe(0);
expect(
countActiveFilters({
q: "a",
kinds: ["IMAGING", "LAB_RESULT"],
episodeId: "e",
year: 2026,
}),
).toBe(5);
});
});
describe("API list search", () => {
it("pins timeline sort, maps episode → episodeId, threads the cursor", () => {
const search = buildVaultListApiSearch(
{ kinds: ["IMAGING"], episodeId: "ep1", year: 2025 },
"cursor-9",
50,
);
const sp = new URLSearchParams(search);
expect(sp.get("kind")).toBe("IMAGING");
expect(sp.get("episodeId")).toBe("ep1");
expect(sp.get("year")).toBe("2025");
expect(sp.get("sort")).toBe("documentDate");
expect(sp.get("order")).toBe("desc");
expect(sp.get("cursor")).toBe("cursor-9");
expect(sp.get("limit")).toBe("50");
});
});
describe("timeline items", () => {
it("buckets by month and chunks rows to the column count", () => {
const docs = [
doc({ id: "a", documentDate: "2026-03-20" }),
doc({ id: "b", documentDate: "2026-03-15" }),
doc({ id: "c", documentDate: "2026-03-10" }),
doc({ id: "d", documentDate: "2026-02-28" }),
];
const items = buildTimelineItems(docs, 2);
expect(items.map((i) => i.type)).toEqual([
"month",
"row",
"row",
"month",
"row",
]);
expect(items[0]).toEqual({ type: "month", key: "2026-03" });
expect(items[1]).toMatchObject({ documents: [docs[0], docs[1]] });
expect(items[2]).toMatchObject({ documents: [docs[2]] });
expect(items[4]).toMatchObject({ documents: [docs[3]] });
});
it("files an undated document under its upload day", () => {
const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" });
expect(documentDateKey(d)).toBe("2026-01-05");
const items = buildTimelineItems([d], 3);
expect(items[0]).toEqual({ type: "month", key: "2026-01" });
});
it("keeps the DOM bounded: item count scales with rows, not documents", () => {
const docs = Array.from({ length: 1000 }, (_, i) =>
doc({ id: `d${i}`, documentDate: "2026-03-01" }),
);
const items = buildTimelineItems(docs, 4);
// 1 month header + 250 rows — the virtualizer then windows over these.
expect(items).toHaveLength(251);
});
it("formats a month key in the viewer's locale", () => {
expect(formatMonthLabel("2026-03", "de")).toBe("März 2026");
expect(formatMonthLabel("not-a-month", "de")).toBe("not-a-month");
});
});
describe("byte formatting", () => {
it("scales units and keeps one decimal below 10", () => {
expect(formatBytes(0, "en")).toBe("0 B");
expect(formatBytes(512, "en")).toBe("512 B");
expect(formatBytes(26_214_400, "en")).toBe("25 MB");
expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB");
});
});
describe("upload response contract (§3.2)", () => {
it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => {
const body = JSON.stringify({
data: doc({ id: "existing-row" }),
error: null,
meta: { duplicate: true },
});
const result = parseUploadResponse(200, body);
expect(result).toMatchObject({
ok: true,
duplicate: true,
document: { id: "existing-row" },
});
});
it("a plain 201 success is not a duplicate", () => {
const body = JSON.stringify({ data: doc({ id: "fresh" }), error: null });
expect(parseUploadResponse(201, body)).toMatchObject({
ok: true,
duplicate: false,
document: { id: "fresh" },
});
});
it("maps every meta.reason to its translated-copy class", () => {
expect(
parseUploadResponse(
413,
JSON.stringify({
data: null,
error: "too large",
meta: { reason: "fileTooLarge", maxFileBytes: 26_214_400 },
}),
),
).toEqual({
ok: false,
reason: "fileTooLarge",
maxFileBytes: 26_214_400,
quotaBytes: undefined,
usedBytes: undefined,
existingId: undefined,
});
expect(
parseUploadResponse(
413,
JSON.stringify({
data: null,
error: "quota",
meta: {
reason: "quotaExceeded",
quotaBytes: 1_073_741_824,
usedBytes: 1_000_000_000,
},
}),
),
).toMatchObject({
ok: false,
reason: "quotaExceeded",
quotaBytes: 1_073_741_824,
usedBytes: 1_000_000_000,
});
expect(
classifyUploadFailure(415, { reason: "unsupportedType" }),
).toMatchObject({ reason: "unsupportedType" });
expect(classifyUploadFailure(409, { reason: "purged" })).toMatchObject({
reason: "purged",
});
expect(
classifyUploadFailure(409, {
reason: "duplicateExists",
existingId: "row-1",
}),
).toMatchObject({ reason: "duplicateExists", existingId: "row-1" });
});
it("falls back to the HTTP status when a proxy strips the body", () => {
expect(parseUploadResponse(413, "")).toMatchObject({
reason: "fileTooLarge",
});
expect(parseUploadResponse(415, "<html>")).toMatchObject({
reason: "unsupportedType",
});
expect(parseUploadResponse(429, "")).toMatchObject({
reason: "rateLimited",
});
expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" });
});
});
describe("shift-click range selection", () => {
const order = ["a", "b", "c", "d", "e"];
it("selects the inclusive range between anchor and target, both ways", () => {
expect(
[...expandRangeSelection(order, new Set(["b"]), "b", "d")].sort(),
).toEqual(["b", "c", "d"]);
expect(
[...expandRangeSelection(order, new Set(["d"]), "d", "b")].sort(),
).toEqual(["b", "c", "d"]);
});
it("is additive — a range gesture never deselects", () => {
const next = expandRangeSelection(order, new Set(["a", "c"]), "c", "e");
expect([...next].sort()).toEqual(["a", "c", "d", "e"]);
});
it("falls back to a plain toggle without a live anchor", () => {
expect([...expandRangeSelection(order, new Set(), null, "c")]).toEqual([
"c",
]);
// Anchor filtered out of the current order → plain toggle (deselect).
expect([
...expandRangeSelection(order, new Set(["c"]), "gone", "c"),
]).toEqual([]);
});
});
@@ -0,0 +1,136 @@
"use client";
/**
* Floating bulk-action bar for the vault's multi-select mode. Appears once
* at least one document is selected, pinned above the bottom nav on phones
* and near the bottom edge on desktop. Carries the selected count and the
* four bulk verbs — set type, link condition, delete (undo-able), clear —
* all driving `POST /api/documents/inbound/bulk` from the page.
*
* A `role="toolbar"` with proper labels; the destructive verb sits last
* before Clear so a stray tap sequence never ends on Delete. The bar is a
* deliberate hand-rolled shell (a floating toolbar is not a Card surface):
* dense-tile padding `p-3` per the standards.
*/
import { FolderPlus, Tag, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
export interface BulkEpisodeOption {
id: string;
label: string;
}
export function DocumentBulkBar({
selectedCount,
episodes,
busy,
onSetKind,
onLinkEpisode,
onDelete,
onClear,
}: {
selectedCount: number;
/** The caller's live illness episodes — the link-condition targets. */
episodes: BulkEpisodeOption[];
/** A bulk call is in flight — the verbs disable, Clear stays live. */
busy: boolean;
onSetKind: (kind: InboundDocumentKindValue) => void;
onLinkEpisode: (episodeId: string) => void;
onDelete: () => void;
onClear: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="document-bulk-bar"
role="toolbar"
aria-label={t("documents.bulk.barLabel")}
className={cn(
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
)}
>
<p className="px-1 text-sm font-medium whitespace-nowrap" role="status">
{t("documents.selection.count", { count: selectedCount })}
</p>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<Tag className="size-4" aria-hidden />
{t("documents.bulk.setKind")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
return (
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
<Icon className="size-4" aria-hidden />
{t(`documents.kind.${kind}`)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<FolderPlus className="size-4" aria-hidden />
{t("documents.bulk.linkCondition")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{episodes.map((episode) => (
<DropdownMenuItem
key={episode.id}
onSelect={() => onLinkEpisode(episode.id)}
>
<span className="max-w-56 truncate">{episode.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{/* Solid destructive (matching the detail sheet's Delete) — the
outline variant's destructive text on the card surface fails the
WCAG contrast gate. */}
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={onDelete}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.bulk.delete")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={onClear}
>
<X className="size-3.5" aria-hidden />
{t("documents.selection.clear")}
</Button>
</div>
</div>
);
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
/**
* One document in the vault timeline (compact-density Card) plus the two
* transient upload-state cards (in-flight with a live progress ring, failed
* with the translated §3.2 reason). All three share the card footprint so an
* optimistic entry morphs into the stored row without the grid jumping.
*
* Anatomy per the design standards: kind icon `text-foreground size-5`,
* title in foreground (`text-sm font-medium`, truncated), one muted meta
* line (date · size · filename), condition-tag pills, an attachment-class
* badge for download-only formats, and a selection checkbox that appears on
* hover / focus / while selected. The whole card is clickable through an
* invisible overlay button; the checkbox floats above it.
*/
import { Download, X } from "lucide-react";
import { useRef } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import { documentDateKey, formatBytes } from "./vault-utils";
/** Press-and-hold duration that flips a touch press into "select" mode. */
const LONG_PRESS_MS = 500;
export function DocumentCard({
document,
selected,
onToggleSelected,
onOpen,
onDelete,
highlighted,
tabIndex = 0,
onCardFocus,
onPrefetch,
}: {
document: InboundDocumentDto;
selected: boolean;
/** `range` = extend the selection from the last anchor (shift-click). */
onToggleSelected: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — undo-able delete owned by the page. */
onDelete?: (id: string) => void;
/** Brief ring after a duplicate upload resolved to this existing row. */
highlighted: boolean;
/** Roving-tabindex slot from the timeline (0 = the one tabbable card). */
tabIndex?: number;
/** The open-button gained focus (keyboard/roving bookkeeping). */
onCardFocus?: (id: string) => void;
/** Hover/focus intent — prefetches the detail metadata (never the blob). */
onPrefetch?: (id: string) => void;
}) {
const { t, locale } = useTranslations();
const format = useFormatters();
// Touch long-press selects instead of opening; the click that the
// browser fires after the release is swallowed once.
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressFired = useRef(false);
const cancelLongPress = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const title =
document.title ?? document.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[document.kind];
const date = format.date(`${documentDateKey(document)}T12:00:00.000Z`);
const size = formatBytes(document.byteSize, locale);
const showFilename =
document.filename !== null && document.filename !== title;
return (
<Card
data-slot="document-card"
data-document-id={document.id}
className={cn(
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
"hover:shadow-sm",
highlighted && "ring-primary ring-2",
)}
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-start gap-2">
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
<p className="min-w-0 flex-1 truncate text-sm font-medium">{title}</p>
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
)}
/>
</div>
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</p>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
{document.conditionLinks.map((link) => (
<span
key={link.episodeId}
className="bg-muted text-foreground inline-flex max-w-40 items-center rounded-full px-2 py-0.5 text-xs"
>
<span className="truncate">{link.name}</span>
</span>
))}
{document.servingClass === "attachment" ? (
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs font-normal"
>
<Download className="size-3" aria-hidden />
{t("documents.card.attachmentBadge")}
</Badge>
) : null}
</div>
) : null}
</CardContent>
{/* Whole-card click target. Painted last so it sits above the content
(the checkbox stays reachable via its own z-10). Keyboard contract
(roving tabindex owned by the timeline): Enter opens (native
click), Space toggles selection, Delete removes with undo. Touch:
press-and-hold selects instead of opening. */}
<button
type="button"
data-slot="document-open"
tabIndex={tabIndex}
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
onOpen(document.id);
}}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
onDelete?.(document.id);
}
}}
onTouchStart={() => {
longPressFired.current = false;
cancelLongPress();
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
onToggleSelected(document.id);
}, LONG_PRESS_MS);
}}
onTouchMove={cancelLongPress}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onMouseEnter={() => onPrefetch?.(document.id)}
onFocus={() => {
onCardFocus?.(document.id);
onPrefetch?.(document.id);
}}
aria-label={t("documents.card.openLabel", { title })}
className="focus-visible:ring-ring/50 absolute inset-0 rounded-xl focus-visible:ring-[3px] focus-visible:outline-none"
/>
</Card>
);
}
/** SVG progress ring for the in-flight upload card (0..1). */
function ProgressRing({ fraction }: { fraction: number }) {
const clamped = Math.min(1, Math.max(0, fraction));
const r = 13;
const circumference = 2 * Math.PI * r;
return (
<svg
viewBox="0 0 32 32"
className="size-8 shrink-0 -rotate-90"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(clamped * 100)}
>
<circle
cx="16"
cy="16"
r={r}
fill="none"
strokeWidth="3"
className="stroke-muted"
/>
<circle
cx="16"
cy="16"
r={r}
fill="none"
strokeWidth="3"
strokeLinecap="round"
className="stroke-primary transition-[stroke-dashoffset] duration-200"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - clamped)}
/>
</svg>
);
}
/**
* Transient card for a queue entry: uploading (progress ring) or failed
* (translated reason + dismiss). Shares the DocumentCard footprint.
*/
export function UploadStateCard({
item,
onDismiss,
}: {
item: UploadQueueItem;
onDismiss: (localId: string) => void;
}) {
const { t, locale } = useTranslations();
const failure = item.failure;
let failureCopy: string | null = null;
if (failure) {
switch (failure.reason) {
case "fileTooLarge":
failureCopy = t("documents.error.fileTooLarge", {
maxSize: formatBytes(failure.maxFileBytes ?? 0, locale),
});
break;
case "quotaExceeded":
failureCopy = t("documents.error.quotaExceeded", {
used: formatBytes(failure.usedBytes ?? 0, locale),
quota: formatBytes(failure.quotaBytes ?? 0, locale),
});
break;
case "unsupportedType":
failureCopy = t("documents.error.unsupportedType");
break;
case "purged":
failureCopy = t("documents.error.purged");
break;
case "duplicateExists":
failureCopy = t("documents.error.duplicateExists");
break;
case "rateLimited":
failureCopy = t("documents.error.rateLimited");
break;
default:
failureCopy = t("documents.error.generic");
}
}
return (
<Card
data-slot="document-upload-card"
className={cn(
"h-full gap-2 border-dashed py-3 md:py-4",
item.status === "error" && "border-destructive/50",
)}
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-center gap-3">
{item.status === "uploading" ? (
<ProgressRing fraction={item.progress} />
) : null}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.fileName}</p>
<p className="text-muted-foreground text-xs">
{item.status === "uploading"
? t("documents.card.uploading")
: formatBytes(item.byteSize, locale)}
</p>
</div>
{item.status === "error" ? (
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={() => onDismiss(item.localId)}
aria-label={t("documents.card.dismissFailed")}
>
<X className="size-4" aria-hidden />
</Button>
) : null}
</div>
{failureCopy ? (
<p role="alert" className="text-destructive text-xs">
{failureCopy}
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,517 @@
"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 { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
apiDelete,
ApiError,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
type 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";
// Chromium's PDF viewer grabs focus while it initialises — BEFORE the
// frame's `load` event — stranding keyboard events inside the embedded
// document, so Escape stops closing the sheet the moment a PDF preview
// opens. While the guard window is open (armed at mount, extended at
// load, ended early by a deliberate pointer interaction with the
// preview), any focus landing on the frame bounces straight back to the
// dialog container; a user who clicks into the PDF is left alone.
const frameRef = useRef<HTMLIFrameElement | null>(null);
const stealGuardUntil = useRef(0);
const reclaimFocus = (frame: HTMLIFrameElement) => {
if (
Date.now() < stealGuardUntil.current &&
document.activeElement === frame
) {
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
}
};
useEffect(() => {
// Arm at mount, then run a frame-by-frame sweep for the steal variants
// that never dispatch a focus event on the frame element.
// Self-terminates with the guard.
stealGuardUntil.current = Date.now() + 3_000;
let raf = 0;
const sweep = () => {
const frame = frameRef.current;
if (!frame || Date.now() >= stealGuardUntil.current) return;
if (document.activeElement === frame) {
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
}
raf = requestAnimationFrame(sweep);
};
raf = requestAnimationFrame(sweep);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className="relative max-h-[55vh] overflow-auto overscroll-contain rounded-lg">
{!loaded ? (
<Skeleton
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
/>
) : null}
{isPdf ? (
// No `sandbox` attribute by design: Chromium force-downloads PDFs
// in sandboxed frames instead of rendering them. The serve
// response itself carries `default-src 'none'` +
// `frame-ancestors 'self'` + true Content-Type + nosniff, and the
// upload policy denies every format whose inline render could
// execute script.
<iframe
ref={frameRef}
src={src}
title={title}
onLoad={(event) => {
setLoaded(true);
// The viewer may still initialise (and steal focus) after
// `load` — keep the guard open a little longer.
stealGuardUntil.current = Math.max(
stealGuardUntil.current,
Date.now() + 2_000,
);
reclaimFocus(event.currentTarget);
}}
onFocus={(event) => reclaimFocus(event.currentTarget)}
onPointerDown={() => {
// Deliberate interaction with the preview ends the guard.
stealGuardUntil.current = 0;
}}
className={cn(
"border-border h-[55vh] w-full rounded-lg border",
!loaded && "absolute inset-0 opacity-0",
)}
/>
) : (
// Decrypted same-origin blob stream: next/image cannot optimise it
// and would only proxy the bytes through a second request.
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={title}
onLoad={() => setLoaded(true)}
className={cn(
"mx-auto max-h-[55vh] w-auto max-w-full rounded-lg object-contain",
!loaded && "absolute inset-0 opacity-0",
)}
/>
)}
</div>
);
}
export function DocumentDetailSheet({
documentId,
open,
onOpenChange,
}: {
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<InboundDocumentDetailDto>(`/api/documents/inbound/${documentId}`),
});
const doc = detail.data;
const episodes = useIllnessEpisodes(true);
const [mutationError, setMutationError] = useState<string | null>(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<InboundDocumentDetailDto>(
`/api/documents/inbound/${documentId}`,
input,
),
onSuccess: () => {
setMutationError(null);
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
onError: () => setMutationError(t("documents.detail.saveError")),
});
const restore = useMutation({
mutationFn: (id: string) =>
apiPost(`/api/documents/inbound/${id}/restore`, {}),
onSuccess: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
onError: (error) => {
const reason =
error instanceof ApiError && typeof error.meta?.reason === "string"
? error.meta.reason
: "";
if (reason === "purged") {
toast.error(t("documents.error.purged"));
} else if (reason === "duplicateExists") {
toast.error(t("documents.error.duplicateExists"));
} else {
toast.error(t("documents.toast.restoreFailed"));
}
},
});
const remove = useMutation({
mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`),
onSuccess: (_data, id) => {
onOpenChange(false);
void invalidateKeys(queryClient, [queryKeys.documents()]);
toast.success(t("documents.toast.deleted"), {
action: {
label: t("common.undo"),
onClick: () => restore.mutate(id),
},
});
},
onError: () => setMutationError(t("documents.detail.deleteError")),
});
const commitTitle = () => {
if (!doc) return;
setEditingTitle(false);
const next = titleDraft.trim();
const current = doc.title ?? "";
if (next === current) return;
patch.mutate({ title: next === "" ? null : next });
};
const toggleEpisode = (episodeId: string) => {
if (!doc) return;
const current = doc.conditionLinks.map((l) => l.episodeId);
const next = current.includes(episodeId)
? current.filter((id) => id !== episodeId)
: [...current, episodeId];
patch.mutate({ episodeIds: next });
};
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
footer={
doc ? (
<>
<Button variant="outline" asChild data-slot="document-download">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
<Button
variant="destructive"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
</>
) : undefined
}
>
{detail.isPending && open ? (
<div className="space-y-3" data-slot="document-detail-loading">
<Skeleton className="h-48 w-full rounded-lg" />
<Skeleton className="h-9 w-2/3" />
<Skeleton className="h-9 w-1/2" />
</div>
) : null}
{detail.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.detail.loadError")}
</p>
) : null}
{doc ? (
<div className="space-y-6">
{doc.servingClass === "inline" ? (
<InlinePreview
documentId={doc.id}
mimeType={doc.mimeType}
title={title}
/>
) : (
<div className="border-border flex flex-col items-center gap-2 rounded-lg border px-4 py-8 text-center">
{Icon ? (
<Icon className="text-muted-foreground size-8" aria-hidden />
) : null}
<p className="max-w-full truncate text-sm font-medium">
{doc.filename ?? title}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.detail.previewUnavailable")} ·{" "}
{formatBytes(doc.byteSize, locale)}
</p>
<Button asChild size="sm" className="mt-1">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
</div>
)}
{mutationError ? (
<p role="alert" className="text-destructive text-sm">
{mutationError}
</p>
) : null}
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="document-title-input">
{t("documents.detail.titleLabel")}
</Label>
{editingTitle ? (
<Input
id="document-title-input"
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") commitTitle();
if (e.key === "Escape") setEditingTitle(false);
}}
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : (
<button
type="button"
id="document-title-input"
onClick={() => {
setTitleDraft(doc.title ?? "");
setEditingTitle(true);
}}
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
<span
className={cn(
"truncate",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</span>
<Pencil
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden
/>
</button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="document-kind-select">
{t("documents.detail.kindLabel")}
</Label>
<Select
value={doc.kind}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
>
<SelectTrigger id="document-kind-select" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOUND_DOCUMENT_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`documents.kind.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-date-field">
{t("documents.detail.dateLabel")}
</Label>
<DateField
id="document-date-field"
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({ documentDate: value === "" ? null : value })
}
aria-label={t("documents.detail.dateLabel")}
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-sm leading-none font-medium">
{t("documents.detail.conditionsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.conditionLinks.map((link) => (
<Link
key={link.episodeId}
href={`/illness/${link.episodeId}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">{link.name}</span>
</Link>
))}
{doc.conditionLinks.length === 0 ? (
<span className="text-muted-foreground text-xs">
{t("documents.detail.noConditions")}
</span>
) : null}
{(episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 rounded-full px-2.5 text-xs"
>
<Plus className="size-3" aria-hidden />
{t("documents.detail.linkCondition")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
{episodes.data?.map((episode) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episode.id,
);
return (
<li key={episode.id}>
<button
type="button"
onClick={() => toggleEpisode(episode.id)}
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
aria-pressed={linked}
>
<Check
className={cn(
"size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="truncate">
{episode.label}
</span>
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
) : null}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
size: formatBytes(doc.byteSize, locale),
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
</div>
</div>
) : null}
</ResponsiveSheet>
);
}
@@ -0,0 +1,202 @@
"use client";
/**
* The vault's sticky filter rail: debounced title/filename search, the
* nine-kind type-chip row (multi-select, OR inside the facet), condition
* chips (episodes that actually carry links), a year segmenter (years
* present in the corpus), and a one-tap clear with the active-facet count.
*
* Purely presentational — the filter state lives in the page URL and is
* owned by `documents-view.tsx`; this bar only renders the controls. It
* sticks below the top edge inside the shell's scroll container (sticky,
* translucent background, border-b — no viewport-height tricks).
*/
import { Search, X } from "lucide-react";
import type { RefObject } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
/** Shared chip chrome — mirrors the app-wide FilterBar pill vocabulary. */
const CHIP_CLASSES =
"border-border bg-card text-foreground inline-flex min-h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none";
const CHIP_ACTIVE_CLASSES = "border-primary/40 bg-primary/10";
export interface ConditionChip {
episodeId: string;
name: string;
}
export function DocumentFilterBar({
searchValue,
onSearchChange,
searchInputRef,
activeKinds,
onToggleKind,
conditionChips,
activeEpisodeId,
onToggleEpisode,
years,
activeYear,
onToggleYear,
activeCount,
onClearAll,
}: {
searchValue: string;
onSearchChange: (value: string) => void;
/** Focused by the page-level `/` shortcut. */
searchInputRef: RefObject<HTMLInputElement | null>;
activeKinds: ReadonlySet<InboundDocumentKindValue>;
onToggleKind: (kind: InboundDocumentKindValue) => void;
conditionChips: ConditionChip[];
activeEpisodeId: string | undefined;
onToggleEpisode: (episodeId: string) => void;
years: number[];
activeYear: number | undefined;
onToggleYear: (year: number) => void;
activeCount: number;
onClearAll: () => void;
}) {
const { t } = useTranslations();
return (
<div
data-slot="document-filter-bar"
className="bg-background/95 border-border sticky top-0 z-10 -mx-4 border-b px-4 pt-1 pb-3 backdrop-blur md:-mx-6 md:px-6"
>
<div className="flex items-center gap-3">
<div className="relative w-full max-w-md">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
ref={searchInputRef}
type="search"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.filter.searchPlaceholder")}
aria-label={t("documents.filter.searchLabel")}
className="pr-8 pl-9"
aria-keyshortcuts="/"
/>
<kbd
aria-hidden
className="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-xs sm:inline-block"
>
/
</kbd>
</div>
{activeCount > 0 ? (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="text-muted-foreground shrink-0"
>
<X className="size-3.5" aria-hidden />
{t("documents.filter.clear")}
{activeCount > 1 ? (
<span className="tabular-nums">({activeCount})</span>
) : null}
</Button>
) : null}
</div>
<div
className="-mx-4 mt-3 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden"
role="group"
aria-label={t("documents.filter.groupLabel")}
>
<span
role="group"
aria-label={t("documents.filter.typeGroup")}
className="contents"
>
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
const active = activeKinds.has(kind);
return (
<button
key={kind}
type="button"
aria-pressed={active}
onClick={() => onToggleKind(kind)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<Icon className="size-3.5 shrink-0" aria-hidden />
{t(`documents.kind.${kind}`)}
</button>
);
})}
</span>
{conditionChips.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.conditionGroup")}
className="contents"
>
{conditionChips.map((chip) => {
const active = chip.episodeId === activeEpisodeId;
return (
<button
key={chip.episodeId}
type="button"
aria-pressed={active}
onClick={() => onToggleEpisode(chip.episodeId)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<span className="max-w-40 truncate">{chip.name}</span>
</button>
);
})}
</span>
</>
) : null}
{years.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.yearGroup")}
className="contents"
>
{years.map((year) => {
const active = year === activeYear;
return (
<button
key={year}
type="button"
aria-pressed={active}
onClick={() => onToggleYear(year)}
className={cn(
CHIP_CLASSES,
"tabular-nums",
active && CHIP_ACTIVE_CLASSES,
)}
>
{year}
</button>
);
})}
</span>
</>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,48 @@
/**
* The vault's kind vocabulary: one Lucide glyph per document kind, in the
* fixed display order the type-chip rail and the cards share. Icons are
* passed as components — the consuming primitive owns size and colour per
* the design standards.
*/
import type { ComponentType } from "react";
import {
ArrowRightLeft,
File,
FileText,
FlaskConical,
Pill,
ScanLine,
ShieldCheck,
Stethoscope,
Syringe,
} from "lucide-react";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
/** Display order for the type-chip rail (everyday kinds first, OTHER last). */
export const DOCUMENT_KIND_ORDER: readonly InboundDocumentKindValue[] = [
"DOCTOR_REPORT",
"LAB_RESULT",
"IMAGING",
"DISCHARGE_LETTER",
"PRESCRIPTION",
"REFERRAL",
"VACCINATION",
"INSURANCE",
"OTHER",
];
export const DOCUMENT_KIND_ICONS: Record<
InboundDocumentKindValue,
ComponentType<{ className?: string }>
> = {
DOCTOR_REPORT: Stethoscope,
DISCHARGE_LETTER: FileText,
LAB_RESULT: FlaskConical,
IMAGING: ScanLine,
PRESCRIPTION: Pill,
REFERRAL: ArrowRightLeft,
INSURANCE: ShieldCheck,
VACCINATION: Syringe,
OTHER: File,
};
@@ -0,0 +1,194 @@
"use client";
/**
* "Verknüpfen" — link existing vault documents to an illness episode from
* the episode's detail page. A `ResponsiveSheet` with a debounced search
* over the vault (title/filename, same recall path as the vault page) and
* one toggle row per document; toggling fires the bulk link/unlink action
* for that single id (idempotent by contract, no-op-success on
* already-in-state rows) and refreshes every document read through the
* `["documents"]` prefix.
*
* Shows one page (50) of matches — the search input is the way to reach an
* older document, mirroring the vault's own recall model.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet, 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 type {
DocumentBulkResultDto,
InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function DocumentLinkPicker({
episodeId,
open,
onOpenChange,
}: {
episodeId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const [searchDraft, setSearchDraft] = useState("");
const [query, setQuery] = useState("");
// 200 ms debounce, matching the vault's search feel.
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const onSearchChange = (value: string) => {
setSearchDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
};
const list = useQuery({
queryKey: queryKeys.inboundDocumentPicker(query),
enabled: open,
queryFn: () => {
const sp = new URLSearchParams();
if (query) sp.set("q", query);
sp.set("sort", "documentDate");
sp.set("order", "desc");
sp.set("limit", "50");
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
const toggle = useMutation({
mutationFn: (input: { documentId: string; linked: boolean }) =>
apiPost<{ results: DocumentBulkResultDto[] }>(
"/api/documents/inbound/bulk",
{
ids: [input.documentId],
action: input.linked ? "unlinkEpisode" : "linkEpisode",
episodeId,
},
),
onSuccess: (data) => {
if (data.results.some((r) => !r.ok)) {
toast.error(t("documents.bulk.failed"));
}
},
onError: () => toast.error(t("documents.bulk.failed")),
onSettled: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
});
const documents = list.data?.documents ?? [];
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("documents.linkPicker.title")}
description={t("documents.linkPicker.description")}
>
<div className="space-y-3">
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.linkPicker.searchPlaceholder")}
aria-label={t("documents.linkPicker.searchPlaceholder")}
className="pl-9"
/>
</div>
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("documents.linkPicker.empty")}
</p>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{documents.map((doc) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episodeId,
);
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={linked}
disabled={toggle.isPending}
onClick={() =>
toggle.mutate({ documentId: doc.id, linked })
}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
linked && "border-primary/40 bg-primary/5",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
<span className="text-muted-foreground block text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
</span>
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
</button>
</li>
);
})}
</ul>
)}
</div>
</ResponsiveSheet>
);
}
@@ -0,0 +1,327 @@
"use client";
/**
* The vault timeline: a virtualized, month-sectioned card grid windowed
* with `@tanstack/react-virtual` over the shell's scroll container
* (`#main-content` — single scroll owner per the design standards; the
* timeline never brings its own scrollport). The flat item list (month
* labels + chunked card rows) comes from `buildTimelineItems`, so the
* mounted DOM stays bounded (< ~400 nodes) regardless of corpus size.
*
* Columns are measured, not breakpoint-classed: a ResizeObserver on the
* grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps
* the virtualizer's row model and the painted grid in lockstep.
*
* In-flight / failed uploads render as a small non-virtualized grid above
* the timeline — they are few (client concurrency 3) and must appear
* < 100 ms after file selection, before any query settles.
*/
import { Loader2 } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslations } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentCard, UploadStateCard } from "./document-card";
import type { UploadQueueItem } from "./use-document-upload";
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
const SCROLL_CONTAINER_ID = "main-content";
/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */
function columnsForWidth(width: number): number {
if (width >= 1200) return 4;
if (width >= 900) return 3;
if (width >= 600) return 2;
return 1;
}
export function DocumentTimeline({
documents,
uploadItems,
onDismissUpload,
hasNextPage,
isFetchingNextPage,
onLoadMore,
selectedIds,
onToggleSelected,
onOpen,
onDelete,
highlightId,
onPrefetch,
}: {
documents: InboundDocumentDto[];
uploadItems: UploadQueueItem[];
onDismissUpload: (localId: string) => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
selectedIds: ReadonlySet<string>;
onToggleSelected: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — the page owns the undo-able delete. */
onDelete?: (id: string) => void;
highlightId: string | null;
onPrefetch?: (id: string) => void;
}) {
const { t, locale } = useTranslations();
const listRef = useRef<HTMLDivElement | null>(null);
const [columns, setColumns] = useState(1);
const [scrollMargin, setScrollMargin] = useState(0);
// Roving tabindex over the card grid: exactly one card is tabbable; the
// arrow keys move the active slot. Falls back to the first document when
// the remembered card left the corpus (filter change, deletion).
const [activeId, setActiveId] = useState<string | null>(null);
// Measured columns — the ResizeObserver drives the row chunking.
useEffect(() => {
const el = listRef.current;
if (!el) return;
const update = () => setColumns(columnsForWidth(el.clientWidth));
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const items = useMemo(
() => buildTimelineItems(documents, columns),
[documents, columns],
);
// The timeline does not start at the scrollport's top edge (page header,
// filter bar, upload row sit above it) — feed the offset to the
// virtualizer so window positions line up. Re-measured when the content
// above changes height (upload cards appearing/leaving) and on resize.
useEffect(() => {
const scrollEl = document.getElementById(SCROLL_CONTAINER_ID);
const listEl = listRef.current;
if (!scrollEl || !listEl) return;
const measure = () => {
const margin =
listEl.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop;
setScrollMargin(margin);
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [uploadItems.length, columns]);
// The React Compiler cannot memoize across TanStack Virtual's instance
// API (library-level opt-out, not a fixable call-site problem) — the
// windowing still works, the compiler just skips this component.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID),
estimateSize: (index) => (items[index].type === "month" ? 40 : 140),
getItemKey: (index) => items[index].key,
overscan: 6,
scrollMargin,
});
const virtualItems = virtualizer.getVirtualItems();
// ── Keyboard navigation (roving tabindex) ─────────────────────────────
// The tabbable slot: the remembered active card, else the first document.
const rovingId =
activeId !== null && documents.some((d) => d.id === activeId)
? activeId
: (documents[0]?.id ?? null);
// Which virtual item (grid row) a document renders in — the arrow-key
// handler scrolls that row into the window before focusing the card.
const rowIndexById = useMemo(() => {
const map = new Map<string, number>();
items.forEach((item, index) => {
if (item.type !== "row") return;
for (const doc of item.documents) map.set(doc.id, index);
});
return map;
}, [items]);
const focusDocument = (id: string) => {
setActiveId(id);
const rowIndex = rowIndexById.get(id);
if (rowIndex !== undefined) {
virtualizer.scrollToIndex(rowIndex, { align: "auto" });
}
// The row may only mount on the next virtualizer paint — retry across
// a few frames, bounded.
let attempts = 0;
const tryFocus = () => {
const button = listRef.current?.querySelector<HTMLButtonElement>(
`[data-document-id="${CSS.escape(id)}"] [data-slot="document-open"]`,
);
if (button) {
button.focus();
return;
}
attempts += 1;
if (attempts < 20) requestAnimationFrame(tryFocus);
};
requestAnimationFrame(tryFocus);
};
const onGridKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (documents.length === 0) return;
const currentIndex = rovingId
? documents.findIndex((d) => d.id === rovingId)
: 0;
let nextIndex: number | null = null;
switch (event.key) {
case "ArrowRight":
nextIndex = Math.min(documents.length - 1, currentIndex + 1);
break;
case "ArrowLeft":
nextIndex = Math.max(0, currentIndex - 1);
break;
case "ArrowDown":
nextIndex = Math.min(documents.length - 1, currentIndex + columns);
break;
case "ArrowUp":
nextIndex = Math.max(0, currentIndex - columns);
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = documents.length - 1;
break;
default:
return;
}
event.preventDefault();
if (nextIndex !== currentIndex) {
focusDocument(documents[nextIndex].id);
}
};
// Keyset infinite feed: pull the next page when the window nears the end.
const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
useEffect(() => {
if (
hasNextPage &&
!isFetchingNextPage &&
lastIndex >= items.length - columns * 3 - 1
) {
onLoadMore();
}
}, [
lastIndex,
items.length,
columns,
hasNextPage,
isFetchingNextPage,
onLoadMore,
]);
return (
<div data-slot="document-timeline" className="space-y-4">
{uploadItems.length > 0 ? (
<div
data-slot="document-upload-queue"
aria-live="polite"
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{uploadItems.map((item) => (
<UploadStateCard
key={item.localId}
item={item}
onDismiss={onDismissUpload}
/>
))}
</div>
) : null}
{/* List semantics over the virtualized window: the container is one
list, each windowed item (month label or card row) one list item —
honest for a windowed structure where per-document posinset would
lie whenever pages are still loading. Keyboard contract on the
grid: arrows move the roving slot, Enter opens, Space selects,
Delete removes (undo-able), documented on the cards. */}
<div
ref={listRef}
role="list"
aria-label={t("documents.timeline.listLabel")}
onKeyDown={onGridKeyDown}
>
<div
className="relative"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualItems.map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
role="listitem"
className="absolute inset-x-0 top-0"
style={{
transform: `translateY(${
virtualItem.start - scrollMargin
}px)`,
}}
>
{item.type === "month" ? (
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
{formatMonthLabel(item.key, locale)}
</h2>
) : (
<div
className="grid gap-4 pb-4"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{item.documents.map((doc) => (
<DocumentCard
key={doc.id}
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={setActiveId}
onPrefetch={onPrefetch}
/>
))}
</div>
)}
</div>
);
})}
</div>
</div>
{isFetchingNextPage ? (
<div
className="text-muted-foreground flex items-center justify-center gap-2 py-4 text-sm"
role="status"
>
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("documents.timeline.loadingMore")}
</div>
) : null}
</div>
);
}
+664
View File
@@ -0,0 +1,664 @@
"use client";
/**
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
*
* Filter state lives in the URL (`?q&kind&episode&year`) so every view is
* shareable, back-button-safe, and deep-linkable from the illness page and
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
* virtualized; uploads appear optimistically above it in < 100 ms.
*
* Born-gated on the resolved `modules.inboundDocuments` flag from
* `GET /api/auth/me` (per-user opt-in AND the operator availability layer).
* An unauthenticated visitor bounces to login; an account without the
* module bounces home. Every `/api/documents/inbound/*` route re-enforces
* the gate server-side — this is a UX redirect, not the security boundary.
*/
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
FolderOpen,
Loader2,
SearchX,
Upload,
UploadCloud,
} from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
import {
DOCUMENT_BULK_MAX_IDS,
type DocumentBulkAction,
type DocumentBulkResultDto,
type DocumentUsageDto,
type InboundDocumentDetailDto,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentBulkBar } from "./document-bulk-bar";
import { DocumentDetailSheet } from "./document-detail-sheet";
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
import { DocumentTimeline } from "./document-timeline";
import { UploadZone } from "./upload-zone";
import { useDocumentUpload } from "./use-document-upload";
import { usePageFileDrop } from "./use-page-file-drop";
import {
buildVaultListApiSearch,
countActiveFilters,
documentDateKey,
expandRangeSelection,
parseVaultSearchParams,
vaultFiltersToSearch,
} from "./vault-utils";
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function DocumentsView() {
const { t } = useTranslations();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const moduleEnabled = user?.modules?.inboundDocuments === true;
// UX redirect only — the API routes enforce the gate server-side.
useEffect(() => {
if (authLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!moduleEnabled) {
router.push("/");
}
}, [authLoading, isAuthenticated, moduleEnabled, router]);
// ── URL-owned filter state ────────────────────────────────────────────
const filters = useMemo(
() => parseVaultSearchParams(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const applyFilters = useCallback(
(next: DocumentVaultFilters, mode: "push" | "replace") => {
const search = vaultFiltersToSearch(next);
const href = search ? `${pathname}?${search}` : pathname;
if (mode === "push") {
router.push(href, { scroll: false });
} else {
router.replace(href, { scroll: false });
}
},
[pathname, router],
);
// Search draft debounces into the URL (200 ms); an external URL change
// (back button, deep link) re-seeds the draft — render-phase derived-state
// adjustment, not an effect.
const [searchDraft, setSearchDraft] = useState(filters.q ?? "");
const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? "");
const searchInputRef = useRef<HTMLInputElement | null>(null);
if ((filters.q ?? "") !== lastUrlQ) {
setLastUrlQ(filters.q ?? "");
setSearchDraft(filters.q ?? "");
}
useEffect(() => {
const handle = setTimeout(() => {
const trimmed = searchDraft.trim();
if (trimmed === (filters.q ?? "")) return;
applyFilters(
{ ...filters, q: trimmed === "" ? undefined : trimmed },
"replace",
);
}, 200);
return () => clearTimeout(handle);
}, [searchDraft, filters, applyFilters]);
// `/` focuses the search from anywhere on the page.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey)
return;
const target = event.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable)
) {
return;
}
event.preventDefault();
searchInputRef.current?.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const toggleKind = (kind: InboundDocumentKindValue) => {
const current = new Set(filters.kinds ?? []);
if (current.has(kind)) {
current.delete(kind);
} else {
current.add(kind);
}
applyFilters(
{
...filters,
kinds: current.size > 0 ? [...current].sort() : undefined,
},
"push",
);
};
const toggleEpisode = (episodeId: string) => {
applyFilters(
{
...filters,
episodeId: filters.episodeId === episodeId ? undefined : episodeId,
},
"push",
);
};
const toggleYear = (year: number) => {
applyFilters(
{ ...filters, year: filters.year === year ? undefined : year },
"push",
);
};
const clearFilters = () => {
setSearchDraft("");
applyFilters({}, "push");
};
// ── Data ──────────────────────────────────────────────────────────────
const usage = useQuery({
queryKey: queryKeys.inboundDocumentUsage(),
enabled: moduleEnabled,
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
});
const list = useInfiniteQuery({
queryKey: queryKeys.inboundDocumentList(filters),
enabled: moduleEnabled,
queryFn: ({ pageParam }) =>
apiGet<ListPage>(
`/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`,
),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const documents = useMemo(
() => list.data?.pages.flatMap((p) => p.documents) ?? [],
[list.data],
);
const upload = useDocumentUpload(
usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined,
);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
// One intake path for every source (picker, zone drop, page-wide drop,
// clipboard paste): pre-links to the active episode filter and feeds the
// sr-only live region — the drop overlay itself is decorative.
const [uploadAnnouncement, setUploadAnnouncement] = useState("");
const enqueueRef = useRef(upload.enqueue);
useEffect(() => {
enqueueRef.current = upload.enqueue;
}, [upload.enqueue]);
const episodeIdFilter = filters.episodeId;
const enqueueFiles = useCallback(
(files: File[]) => {
if (files.length === 0) return;
enqueueRef.current(files, { episodeId: episodeIdFilter });
setUploadAnnouncement(
t("documents.upload.queuedAnnouncement", { count: files.length }),
);
},
[episodeIdFilter, t],
);
const { dropActive } = usePageFileDrop(enqueueFiles);
const episodes = useIllnessEpisodes(true);
// Condition chips: every episode carrying at least one live document
// link, served by the usage endpoint (NOT derived from the loaded corpus
// — an old linked document pages deep must still surface its chip), plus
// the actively filtered episode (a deep link must always show its own
// chip, even when its last link was just removed).
const conditionChips = useMemo<ConditionChip[]>(() => {
const byId = new Map<string, string>();
for (const link of usage.data?.linkedEpisodes ?? []) {
byId.set(link.episodeId, link.name);
}
if (filters.episodeId && !byId.has(filters.episodeId)) {
const episode = episodes.data?.find((e) => e.id === filters.episodeId);
byId.set(filters.episodeId, episode?.label ?? filters.episodeId);
}
return [...byId.entries()]
.map(([episodeId, name]) => ({ episodeId, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
// Year segmenter: years present in the loaded corpus (+ the active year).
const years = useMemo(() => {
const set = new Set<number>();
for (const doc of documents) {
set.add(Number(documentDateKey(doc).slice(0, 4)));
}
if (filters.year !== undefined) set.add(filters.year);
return [...set].sort((a, b) => b - a);
}, [documents, filters.year]);
// ── Selection + bulk actions ──────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
new Set(),
);
// Anchor for shift-click ranges: the last plainly-toggled id.
const rangeAnchorRef = useRef<string | null>(null);
const toggleSelected = useCallback(
(id: string, range?: boolean) => {
setSelectedIds((prev) => {
if (range) {
return expandRangeSelection(
documents.map((d) => d.id),
prev,
rangeAnchorRef.current,
id,
);
}
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
if (!range) rangeAnchorRef.current = id;
},
[documents],
);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
rangeAnchorRef.current = null;
}, []);
// Escape clears the selection — unless a dialog/popover owns the key.
useEffect(() => {
if (selectedIds.size === 0) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
const target = event.target as HTMLElement | null;
if (target?.closest('[role="dialog"],[role="menu"]')) return;
clearSelection();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [selectedIds.size, clearSelection]);
/**
* One bulk POST per ≤100-id chunk (the endpoint's cap); per-id results
* are merged so a partial failure in any chunk surfaces once. The
* endpoint is no-op-success on already-in-state rows, so an undo toast
* firing twice is safe by contract.
*/
const bulk = useMutation({
mutationFn: async (input: {
ids: string[];
action: DocumentBulkAction;
kind?: InboundDocumentKindValue;
episodeId?: string;
}) => {
const results: DocumentBulkResultDto[] = [];
for (let i = 0; i < input.ids.length; i += DOCUMENT_BULK_MAX_IDS) {
const chunk = input.ids.slice(i, i + DOCUMENT_BULK_MAX_IDS);
const page = await apiPost<{ results: DocumentBulkResultDto[] }>(
"/api/documents/inbound/bulk",
{
ids: chunk,
action: input.action,
...(input.kind !== undefined && { kind: input.kind }),
...(input.episodeId !== undefined && {
episodeId: input.episodeId,
}),
},
);
results.push(...page.results);
}
return results;
},
onSettled: () => {
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
});
const reportBulkOutcome = useCallback(
(results: DocumentBulkResultDto[], successMessage: string) => {
const failed = results.filter((r) => !r.ok).length;
if (failed > 0) {
toast.error(
t("documents.bulk.partialFailure", {
failed,
total: results.length,
}),
);
} else {
toast.success(successMessage);
}
},
[t],
);
const runBulk = useCallback(
(
action: Exclude<DocumentBulkAction, "delete" | "restore">,
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
) => {
const ids = [...selectedIds];
bulk.mutate(
{ ids, action, ...extra },
{
onSuccess: (results) => {
reportBulkOutcome(
results,
t("documents.bulk.updated", {
count: results.filter((r) => r.ok).length,
}),
);
clearSelection();
},
onError: () => toast.error(t("documents.bulk.failed")),
},
);
},
[selectedIds, bulk, reportBulkOutcome, clearSelection, t],
);
const restoreBulk = useCallback(
(ids: string[]) => {
bulk.mutate(
{ ids, action: "restore" },
{
onSuccess: (results) =>
reportBulkOutcome(
results,
t("documents.bulk.restored", {
count: results.filter((r) => r.ok).length,
}),
),
onError: () => toast.error(t("documents.toast.restoreFailed")),
},
);
},
[bulk, reportBulkOutcome, t],
);
const deleteBulk = useCallback(
(ids: string[]) => {
bulk.mutate(
{ ids, action: "delete" },
{
onSuccess: (results) => {
const okIds = results.filter((r) => r.ok).map((r) => r.id);
const failed = results.length - okIds.length;
if (failed > 0) {
toast.error(
t("documents.bulk.partialFailure", {
failed,
total: results.length,
}),
);
}
if (okIds.length > 0) {
// ONE aggregate undo for the whole batch (bulk restore).
toast.success(
t("documents.bulk.deleted", { count: okIds.length }),
{
action: {
label: t("common.undo"),
onClick: () => restoreBulk(okIds),
},
},
);
}
clearSelection();
},
onError: () => toast.error(t("documents.bulk.failed")),
},
);
},
[bulk, clearSelection, restoreBulk, t],
);
// ── Detail sheet ──────────────────────────────────────────────────────
const [detailId, setDetailId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const openDetail = useCallback((id: string) => {
setDetailId(id);
setDetailOpen(true);
}, []);
// `?doc=<id>` deep link (used by the illness page's document rows): open
// the detail sheet once per value; closing the sheet strips the param so
// back/forward and refresh behave. Render-phase derived-state adjustment
// (the file's established pattern), consumed only once auth has resolved
// so a deep link never burns before the module flag is known. The id is
// shape-checked before it is interpolated into an API path.
const docParam = searchParams.get("doc");
const [consumedDocParam, setConsumedDocParam] = useState<string | null>(null);
if (!authLoading && moduleEnabled && docParam !== consumedDocParam) {
setConsumedDocParam(docParam);
if (docParam !== null && /^[a-zA-Z0-9_-]{1,40}$/.test(docParam)) {
setDetailId(docParam);
setDetailOpen(true);
}
}
const handleDetailOpenChange = useCallback(
(open: boolean) => {
setDetailOpen(open);
if (!open && searchParams.get("doc")) {
const sp = new URLSearchParams(searchParams.toString());
sp.delete("doc");
const search = sp.toString();
router.replace(search ? `${pathname}?${search}` : pathname, {
scroll: false,
});
}
},
[pathname, router, searchParams],
);
// Hover/focus intent prefetches the detail METADATA (never the blob —
// the blob fetch starts when the sheet mounts its preview element).
const prefetchDetail = useCallback(
(id: string) => {
void queryClient.prefetchQuery({
queryKey: queryKeys.inboundDocument(id),
queryFn: () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${id}`),
staleTime: 30_000,
});
},
[queryClient],
);
if (authLoading || !isAuthenticated || !moduleEnabled) {
return (
<div className="flex h-64 items-center justify-center" role="status">
<Loader2
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
aria-hidden
/>
<span className="sr-only">{t("nav.loadingScreen")}</span>
</div>
);
}
const activeCount = countActiveFilters(filters);
const isFiltered = activeCount > 0;
const showEmpty =
list.isSuccess && documents.length === 0 && upload.items.length === 0;
return (
<div className="space-y-6">
<PageHeader
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
}
/>
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={enqueueFiles}
/>
<DocumentFilterBar
searchValue={searchDraft}
onSearchChange={setSearchDraft}
searchInputRef={searchInputRef}
activeKinds={new Set(filters.kinds ?? [])}
onToggleKind={toggleKind}
conditionChips={conditionChips}
activeEpisodeId={filters.episodeId}
onToggleEpisode={toggleEpisode}
years={years}
activeYear={filters.year}
onToggleYear={toggleYear}
activeCount={activeCount}
onClearAll={clearFilters}
/>
{list.isPending ? (
<div
data-slot="documents-loading"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{Array.from({ length: 9 }, (_, i) => (
<Skeleton key={i} className="h-28 rounded-xl" />
))}
</div>
) : list.isError ? (
<QueryErrorCard
title={t("documents.list.loadError")}
onRetry={() => void list.refetch()}
/>
) : showEmpty && !isFiltered ? (
<EmptyState
icon={<FolderOpen className="size-6" aria-hidden />}
title={t("documents.empty.title")}
description={t("documents.empty.description")}
ctaSize="lg"
action={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.empty.action")}
</Button>
}
/>
) : showEmpty && isFiltered ? (
<EmptyState
icon={<SearchX className="size-6" aria-hidden />}
title={t("documents.empty.noMatchesTitle")}
description={t("documents.empty.noMatchesDescription")}
action={
<Button variant="outline" onClick={clearFilters}>
{t("documents.filter.clear")}
</Button>
}
/>
) : (
<DocumentTimeline
documents={documents}
uploadItems={upload.items}
onDismissUpload={upload.dismiss}
hasNextPage={list.hasNextPage}
isFetchingNextPage={list.isFetchingNextPage}
onLoadMore={() => void list.fetchNextPage()}
selectedIds={selectedIds}
onToggleSelected={toggleSelected}
onOpen={openDetail}
onDelete={(id) => deleteBulk([id])}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>
)}
<DocumentDetailSheet
documentId={detailId}
open={detailOpen}
onOpenChange={handleDetailOpenChange}
/>
{selectedIds.size > 0 ? (
<DocumentBulkBar
selectedCount={selectedIds.size}
episodes={(episodes.data ?? []).map((e) => ({
id: e.id,
label: e.label,
}))}
busy={bulk.isPending}
onSetKind={(kind) => runBulk("setKind", { kind })}
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
onDelete={() => deleteBulk([...selectedIds])}
onClear={clearSelection}
/>
) : null}
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
itself announces through the live region below. */}
{dropActive ? (
<div
aria-hidden
data-slot="document-drop-overlay"
className="bg-background/80 fixed inset-0 z-50 p-4 backdrop-blur-sm md:p-8"
>
<div className="border-primary bg-primary/5 flex h-full items-center justify-center rounded-xl border-2 border-dashed">
<div className="flex flex-col items-center gap-2 px-4 text-center">
<UploadCloud className="text-primary size-8" aria-hidden />
<p className="text-base font-medium">
{t("documents.dropOverlay.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.dropOverlay.hint")}
</p>
</div>
</div>
</div>
) : null}
<p aria-live="polite" role="status" className="sr-only">
{uploadAnnouncement}
</p>
</div>
);
}
@@ -0,0 +1,179 @@
"use client";
/**
* The "Dokumente" section on an illness-episode detail page — the
* condition side of the condition ⇄ documents linking (the document side
* links back via the detail sheet's condition chips). Lists the episode's
* most recent linked documents as compact rows deep-linking straight into
* the vault (`?episode=…&doc=…` opens the detail sheet), plus the three
* affordances: link an existing document, upload pre-linked (deep link
* with the episode filter active — the vault threads it into the upload),
* and the filtered "show all" view.
*
* Renders nothing when the documents module is off for this account —
* module-gated surfaces never leak. An episode without documents shows a
* quiet one-line affordance, not a teaching empty state.
*/
import { useQuery } from "@tanstack/react-query";
import { FolderOpen, Link2, Upload } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ListRow } from "@/components/ui/list-row";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentLinkPicker } from "./document-link-picker";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
/** Compact preview size — "Alle anzeigen" carries the long tail. */
const PREVIEW_LIMIT = 5;
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const format = useFormatters();
const { user } = useAuth();
const [pickerOpen, setPickerOpen] = useState(false);
const moduleEnabled = user?.modules?.inboundDocuments === true;
const list = useQuery({
queryKey: queryKeys.inboundDocumentEpisodePreview(episodeId),
enabled: moduleEnabled,
queryFn: () => {
const sp = new URLSearchParams();
sp.set("episodeId", episodeId);
sp.set("sort", "documentDate");
sp.set("order", "desc");
sp.set("limit", String(PREVIEW_LIMIT + 1));
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
if (!moduleEnabled) return null;
if (list.isError) {
return (
<QueryErrorCard
title={t("documents.episodeCard.loadError")}
onRetry={() => void list.refetch()}
/>
);
}
const documents = (list.data?.documents ?? []).slice(0, PREVIEW_LIMIT);
const hasMore =
(list.data?.documents.length ?? 0) > PREVIEW_LIMIT ||
list.data?.nextCursor != null;
const vaultHref = `/documents?episode=${encodeURIComponent(episodeId)}`;
return (
<>
<Card data-slot="episode-documents-card">
<CardContent className="space-y-4">
<TileHeader
icon={FolderOpen}
title={t("documents.episodeCard.title")}
right={
documents.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{documents.length}
{hasMore ? "+" : ""}
</span>
) : undefined
}
/>
{list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.episodeCard.emptyLine")}
</p>
) : (
<ul className="space-y-1.5">
{documents.map((doc) => {
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<ListRow asChild>
<Link
href={`${vaultHref}&doc=${encodeURIComponent(doc.id)}`}
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{title}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
</span>
</Link>
</ListRow>
</li>
);
})}
</ul>
)}
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Link2 className="size-4" aria-hidden />
{t("documents.episodeCard.link")}
</Button>
<Button asChild variant="outline" size="sm">
<Link href={vaultHref}>
<Upload className="size-4" aria-hidden />
{t("documents.episodeCard.upload")}
</Link>
</Button>
{hasMore ? (
<Button
asChild
variant="ghost"
size="sm"
className="text-muted-foreground ml-auto"
>
<Link href={vaultHref}>
{t("documents.episodeCard.viewAll")}
</Link>
</Button>
) : null}
</div>
</CardContent>
</Card>
<DocumentLinkPicker
episodeId={episodeId}
open={pickerOpen}
onOpenChange={setPickerOpen}
/>
</>
);
}
File diff suppressed because it is too large. Load diff
-197
View File
@@ -1,197 +0,0 @@
/**
* v1.25 — pure helpers for the documents library (search-param building, date
* grouping, provider-unsupported classification). Kept out of the client
* component so they stay trivially unit-testable without a render harness.
*/
import { ApiError } from "@/lib/api/api-fetch";
import type { DocumentListParams } from "@/lib/query-keys/documents";
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
/**
* Build the `/api/documents/inbound` query string from the active filters and
* a keyset cursor. Empty / undefined fields are omitted so the URL stays clean
* and the server applies its own defaults. The cursor is the `nextCursor` the
* previous page returned (null on the first page).
*/
export function buildDocumentListSearch(
params: DocumentListParams,
cursor: string | null,
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
): string {
const sp = new URLSearchParams();
if (params.q && params.q.trim() !== "") sp.set("q", params.q.trim());
if (params.kind) sp.set("kind", params.kind);
if (params.from) sp.set("from", params.from);
if (params.to) sp.set("to", params.to);
sp.set("sort", params.sort);
sp.set("order", params.order);
sp.set("limit", String(limit));
if (cursor) sp.set("cursor", cursor);
return sp.toString();
}
/** The date a document files under — its user filing date, else its upload day. */
export function documentDateKey(doc: InboundDocumentDto): string {
if (doc.documentDate) return doc.documentDate;
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
return doc.createdAt.slice(0, 10);
}
export interface DocumentDateGroup {
/** The YYYY-MM-DD key the group files under. */
key: string;
documents: InboundDocumentDto[];
}
/**
* Group an already-sorted document list into consecutive date buckets,
* preserving the server's sort order. A document's bucket is its `documentDate`
* (fallback: its upload day). The first time a date appears it opens a new
* group; later documents on the same date join it. Order is preserved exactly
* as received, so the server stays the single source of truth for sorting.
*/
export function groupDocumentsByDate(
documents: InboundDocumentDto[],
): DocumentDateGroup[] {
const groups: DocumentDateGroup[] = [];
let current: DocumentDateGroup | null = null;
for (const doc of documents) {
const key = documentDateKey(doc);
if (!current || current.key !== key) {
current = { key, documents: [doc] };
groups.push(current);
} else {
current.documents.push(doc);
}
}
return groups;
}
/**
* True when an error is the extract route's 422 "no document-scan provider
* configured" signal. The UI surfaces this as a calm inline note (configure a
* provider) rather than a hard error toast — the stored document is untouched.
*/
export function isProviderUnsupportedError(error: unknown): boolean {
return (
error instanceof ApiError &&
error.meta?.errorCode === "documents.inbound.providerUnsupported"
);
}
/**
* True when an error is the extract route's "already confirmed" signal. Two
* shapes reach here:
* - 409 `alreadyPartlyConfirmed` — some facts approved, the rest still pending;
* - 422 `alreadyConfirmed` — the whole document is CONFIRMED (a cross-tab race
* can fire Extract against a document a second tab just finished confirming).
* Re-extraction is refused server-side in both cases; the UI surfaces a calm
* inline note rather than a hard error toast, and hides the Extract control to
* match.
*/
export function isAlreadyConfirmedError(error: unknown): boolean {
if (!(error instanceof ApiError)) return false;
const code = error.meta?.errorCode;
return (
code === "documents.inbound.alreadyPartlyConfirmed" ||
code === "documents.inbound.alreadyConfirmed"
);
}
/**
* True when an extract failure is the route's "local OCR not enabled" signal
* (`documents.inbound.localOcrDisabled`, 422). The TEXT extract path refuses
* when in-browser OCR is switched off in settings; surface a clear message that
* names the setting rather than the generic "couldn't extract" toast.
*/
export function isLocalOcrDisabledError(error: unknown): boolean {
return (
error instanceof ApiError &&
error.meta?.errorCode === "documents.inbound.localOcrDisabled"
);
}
/**
* Map a per-fact commit-failure code to the i18n key for its plain-language
* reason. The confirm route returns HTTP 200 even when it rejects a fact into
* its `failed[]` array (e.g. a stated unit that disagrees with the saved
* marker), so the client must surface why — otherwise the fact silently stays
* PENDING with the user told nothing.
*/
export function commitFailureReasonKey(code: string): string {
switch (code) {
case "observation.unitMismatch":
return "documents.review.commitError.unitMismatch";
case "observation.unitRequired":
return "documents.review.commitError.unitRequired";
default:
return "documents.review.commitError.generic";
}
}
/**
* Pick the single reason key that best describes a batch of failed facts: the
* shared reason when every failure agrees, else a generic fallback. Used to
* build the partial-failure toast after a confirm that rejected one or more
* facts server-side.
*/
export function confirmFailureReasonKey(failed: { reason: string }[]): string {
const codes = new Set(failed.map((f) => f.reason));
if (codes.size === 1) return commitFailureReasonKey([...codes][0]);
return "documents.review.commitError.generic";
}
/**
* Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an
* oversized file is rejected before it is sent, instead of after a full upload
* round-trips and returns 413.
*/
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
/**
* The class of an upload (`POST /api/documents/inbound`) failure. The route
* reports precise, actionable codes the generic toast would throw away; this
* maps them to a stable kind so the view can show the right message and keep
* every `t()` key a literal call site.
*/
export type UploadErrorKind =
"tooLarge" | "fileType" | "rateLimited" | "invalidMetadata" | "generic";
/** Classify an upload failure by the route's `errorCode`, then HTTP status. */
export function classifyUploadError(error: unknown): UploadErrorKind {
if (!(error instanceof ApiError)) return "generic";
switch (error.meta?.errorCode) {
case "documents.inbound.fileTooLarge":
return "tooLarge";
case "documents.inbound.fileType":
return "fileType";
case "documents.inbound.rateLimited":
return "rateLimited";
case "documents.inbound.invalidMetadata":
return "invalidMetadata";
}
// A reverse proxy can strip the JSON body (and its `meta`) on some statuses;
// fall back to the HTTP code so size / type / rate-limit still classify.
if (error.status === 413) return "tooLarge";
if (error.status === 415) return "fileType";
if (error.status === 429) return "rateLimited";
return "generic";
}
/**
* Format a YYYY-MM-DD group key into a date label via the app's own date
* formatter (`useFormatters().date`), so the group headers follow the user's
* date-format preference (numeric) like the rest of the app instead of a textual
* `dateStyle: "medium"` month. The key is handed over as a noon-UTC instant so
* the calendar day never shifts under the display timezone.
*/
export function formatDateGroupLabel(
key: string,
formatDate: (value: string) => string,
): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return key;
return formatDate(`${key}T12:00:00.000Z`);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
/**
* The vault's upload entry: a dashed drop-target card with a file button.
* The `accept` list comes verbatim from the usage endpoint (HEIC is
* deliberately absent so the iOS picker transcodes camera photos to JPEG),
* `multiple` is on, and there is NO `capture` attribute — mobile Safari
* then offers camera AND library. Dropping files onto the card feeds the
* same queue as the picker.
*
* The quota bar renders only above 80 % usage — a calm surface until
* storage actually becomes a topic.
*/
import { UploadCloud } from "lucide-react";
import { useRef, useState, type RefObject } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
import { formatBytes } from "./vault-utils";
/** Usage fraction above which the quota bar appears. */
const QUOTA_BAR_THRESHOLD = 0.8;
export function UploadZone({
usage,
onFiles,
inputRef,
}: {
/** Limits + usage from `GET /api/documents/inbound/usage` (undefined while loading). */
usage: DocumentUsageDto | undefined;
onFiles: (files: File[]) => void;
/** Exposed so the page header's Upload button can open the same picker. */
inputRef: RefObject<HTMLInputElement | null>;
}) {
const { t, locale } = useTranslations();
const [dragOver, setDragOver] = useState(false);
const dragDepth = useRef(0);
const accept = usage?.acceptedExtensions.join(",");
const usedFraction =
usage && usage.quotaBytes > 0 ? usage.usedBytes / usage.quotaBytes : 0;
const pickFiles = (list: FileList | null) => {
if (!list || list.length === 0) return;
onFiles(Array.from(list));
};
return (
<div data-slot="document-upload-zone" className="space-y-2">
<div
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current += 1;
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
pickFiles(e.dataTransfer.files);
}}
className={cn(
"border-border rounded-xl border-2 border-dashed px-4 py-6 text-center transition-colors",
dragOver && "border-primary bg-primary/5",
)}
>
<div className="flex flex-col items-center gap-2">
<UploadCloud
className={cn(
"text-muted-foreground size-6",
dragOver && "text-primary",
)}
aria-hidden
/>
<p className="text-sm font-medium">
{t("documents.upload.zoneTitle")}
</p>
{usage ? (
<p className="text-muted-foreground text-xs">
{t("documents.upload.zoneHint", {
maxSize: formatBytes(usage.maxFileBytes, locale),
})}
</p>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => inputRef.current?.click()}
>
{t("documents.upload.browse")}
</Button>
</div>
<input
ref={inputRef}
type="file"
multiple
accept={accept}
className="sr-only"
aria-label={t("documents.upload.browse")}
onChange={(e) => {
pickFiles(e.target.files);
// Allow re-selecting the same file (duplicate flow) immediately.
e.target.value = "";
}}
/>
</div>
{usage && usedFraction >= QUOTA_BAR_THRESHOLD ? (
<div data-slot="document-quota-bar" className="space-y-1">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{t("documents.upload.quotaAlmostFull")}
</p>
<p className="text-muted-foreground text-xs tabular-nums">
{t("documents.upload.quotaUsed", {
used: formatBytes(usage.usedBytes, locale),
quota: formatBytes(usage.quotaBytes, locale),
})}
</p>
</div>
<Progress
value={Math.min(100, usedFraction * 100)}
aria-label={t("documents.upload.quotaUsed", {
used: formatBytes(usage.usedBytes, locale),
quota: formatBytes(usage.quotaBytes, locale),
})}
/>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
/**
* The vault's upload manager: a client-side queue with concurrency 3, one
* multipart XHR POST per file (XHR because it exposes real upload-progress
* events with zero dependencies — fetch upload streaming is not portable),
* client-side image downscaling, and per-file terminal states.
*
* Perceived-speed contract: `enqueue()` adds pending entries synchronously
* (< 100 ms — before any network work), each carrying live progress. A
* finished file leaves the queue and the list/usage queries refetch; a
* failed file stays as an inline error card until dismissed (an over-limit
* file fails individually, the batch continues).
*
* Duplicate contract (§3.2): a same-user re-upload returns HTTP 200 with
* envelope-level `meta.duplicate: true` and the EXISTING row — the manager
* toasts "already stored" and hands the row id to the timeline for a brief
* highlight. This is the one call site that must read success-side `meta`;
* `parseUploadResponse` in `vault-utils.ts` owns that parse.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
parseUploadResponse,
type UploadFailure,
type UploadResult,
} from "./vault-utils";
/** Client-side parallelism — matches the plan's NAS memory envelope. */
const CONCURRENCY = 3;
/** Longest-edge ceiling for the client-side image downscale. */
const IMAGE_MAX_EDGE = 3000;
/** How long a duplicate-highlight ring stays on the existing row. */
const HIGHLIGHT_MS = 2600;
/** Only raster photo formats are re-encoded — never PDFs/Office, never GIF
* (re-encoding would flatten an animation and GIF photos don't exist). */
const DOWNSCALE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
export interface UploadQueueItem {
/** Local id — NOT a server id; stable for the item's queue lifetime. */
localId: string;
fileName: string;
byteSize: number;
status: "uploading" | "error";
/** 0..1 upload progress (transport bytes, not server processing). */
progress: number;
/** Terminal failure detail, present when `status === "error"`. */
failure?: UploadFailure;
}
export interface EnqueueOptions {
/** Pre-link every uploaded document to this episode (deep-link uploads). */
episodeId?: string;
}
interface UploadLimits {
maxFileBytes: number;
}
/**
* Downscale a raster photo to ≤ 3000 px longest edge (canvas, JPEG 0.85).
* Returns the original file untouched when the type is not a raster photo,
* the image is already small enough, or anything about decoding fails —
* a downscale must never block an upload.
*/
async function downscaleImage(file: File): Promise<File> {
if (!DOWNSCALE_TYPES.has(file.type)) return file;
try {
const bitmap = await createImageBitmap(file);
const longest = Math.max(bitmap.width, bitmap.height);
if (longest <= IMAGE_MAX_EDGE) {
bitmap.close();
return file;
}
const scale = IMAGE_MAX_EDGE / longest;
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.85),
);
if (!blob) return file;
const stem = file.name.replace(/\.[^.]+$/u, "") || "photo";
return new File([blob], `${stem}.jpg`, { type: "image/jpeg" });
} catch {
return file;
}
}
/** One multipart POST via XHR, reporting upload progress. */
function uploadViaXhr(
file: File,
options: EnqueueOptions,
onProgress: (fraction: number) => void,
): Promise<UploadResult> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/documents/inbound");
// Retry-safe by construction: the server honours Idempotency-Key, so a
// network blip that DID land server-side cannot double-store on retry.
xhr.setRequestHeader("Idempotency-Key", crypto.randomUUID());
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(event.loaded / event.total);
}
});
xhr.addEventListener("load", () => {
resolve(parseUploadResponse(xhr.status, xhr.responseText));
});
xhr.addEventListener("error", () =>
resolve({ ok: false, reason: "generic" }),
);
xhr.addEventListener("abort", () =>
resolve({ ok: false, reason: "generic" }),
);
const fd = new FormData();
fd.append("file", file);
if (options.episodeId) fd.append("episodeIds", options.episodeId);
xhr.send(fd);
});
}
export interface DocumentUploadManager {
/** Live queue — uploading entries first-in-first, then error entries. */
items: UploadQueueItem[];
/** Add files to the queue; returns immediately (optimistic entries). */
enqueue: (files: File[], options?: EnqueueOptions) => void;
/** Remove a terminal (error) entry from the queue. */
dismiss: (localId: string) => void;
/** Row id to briefly highlight (duplicate upload → the existing row). */
highlightId: string | null;
}
export function useDocumentUpload(
limits: UploadLimits | undefined,
): DocumentUploadManager {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [items, setItems] = useState<UploadQueueItem[]>([]);
const [highlightId, setHighlightId] = useState<string | null>(null);
const activeRef = useRef(0);
const pendingRef = useRef<
{ localId: string; file: File; options: EnqueueOptions }[]
>([]);
const mountedRef = useRef(true);
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (highlightTimer.current) clearTimeout(highlightTimer.current);
};
}, []);
const patchItem = useCallback(
(localId: string, patch: Partial<UploadQueueItem>) => {
if (!mountedRef.current) return;
setItems((prev) =>
prev.map((it) => (it.localId === localId ? { ...it, ...patch } : it)),
);
},
[],
);
const removeItem = useCallback((localId: string) => {
if (!mountedRef.current) return;
setItems((prev) => prev.filter((it) => it.localId !== localId));
}, []);
const flashHighlight = useCallback((id: string) => {
if (!mountedRef.current) return;
setHighlightId(id);
if (highlightTimer.current) clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => {
if (mountedRef.current) setHighlightId(null);
}, HIGHLIGHT_MS);
}, []);
const settle = useCallback(
(localId: string, result: UploadResult) => {
if (result.ok) {
removeItem(localId);
void invalidateKeys(queryClient, [queryKeys.documents()]);
if (result.duplicate) {
toast.info(t("documents.toast.duplicate"));
flashHighlight(result.document.id);
}
return;
}
patchItem(localId, { status: "error", failure: result, progress: 0 });
},
[flashHighlight, patchItem, queryClient, removeItem, t],
);
const pump = useCallback(() => {
while (
activeRef.current < CONCURRENCY &&
pendingRef.current.length > 0 &&
mountedRef.current
) {
const next = pendingRef.current.shift();
if (!next) return;
activeRef.current += 1;
void (async () => {
try {
const prepared = await downscaleImage(next.file);
const result = await uploadViaXhr(prepared, next.options, (f) =>
patchItem(next.localId, { progress: f }),
);
settle(next.localId, result);
} finally {
activeRef.current -= 1;
pump();
}
})();
}
}, [patchItem, settle]);
const enqueue = useCallback(
(files: File[], options: EnqueueOptions = {}) => {
if (files.length === 0) return;
const fresh: UploadQueueItem[] = [];
for (const file of files) {
const localId = crypto.randomUUID();
// Client pre-flight against the server's configured cap: an obvious
// giant (a CD image) fails instantly and locally — friendly reason,
// no wasted transfer. The server re-enforces regardless.
if (limits && file.size > limits.maxFileBytes) {
fresh.push({
localId,
fileName: file.name,
byteSize: file.size,
status: "error",
progress: 0,
failure: {
ok: false,
reason: "fileTooLarge",
maxFileBytes: limits.maxFileBytes,
},
});
continue;
}
fresh.push({
localId,
fileName: file.name,
byteSize: file.size,
status: "uploading",
progress: 0,
});
pendingRef.current.push({ localId, file, options });
}
setItems((prev) => [...prev, ...fresh]);
pump();
},
[limits, pump],
);
const dismiss = useCallback(
(localId: string) => removeItem(localId),
[removeItem],
);
return { items, enqueue, dismiss, highlightId };
}
@@ -0,0 +1,93 @@
"use client";
/**
* Page-wide file intake for the vault: dragging files anywhere over the
* window raises a full-page drop overlay (the whole page is the target —
* not just the upload zone card), and pasting files/screenshots from the
* clipboard feeds the same queue. Window-level listeners, mounted only
* while `/documents` is on screen.
*
* Details that matter:
* - Only FILE drags react (`dataTransfer.types` includes `"Files"`) — text
* selections and in-page drags never raise the overlay.
* - `dragenter`/`dragleave` fire per element; a depth counter tells "left
* the window" apart from "moved between children".
* - `dragover` must be prevented while a file drag is over the window or
* the browser navigates to the dropped file on a miss.
* - Escape dismisses the overlay (the OS drag itself cannot be cancelled
* from the page; a subsequent drop then falls through to the browser
* default outside the page contract).
* - Paste only intercepts when the clipboard actually carries files, so
* pasting text into the search input stays untouched.
*/
import { useEffect, useRef, useState } from "react";
export function usePageFileDrop(onFiles: (files: File[]) => void): {
dropActive: boolean;
} {
const [dropActive, setDropActive] = useState(false);
const depthRef = useRef(0);
const onFilesRef = useRef(onFiles);
useEffect(() => {
onFilesRef.current = onFiles;
}, [onFiles]);
useEffect(() => {
const isFileDrag = (event: DragEvent) =>
event.dataTransfer?.types?.includes("Files") ?? false;
const onDragEnter = (event: DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depthRef.current += 1;
setDropActive(true);
};
const onDragOver = (event: DragEvent) => {
if (!isFileDrag(event)) return;
// Without this the browser handles the drop itself (navigates away).
event.preventDefault();
};
const onDragLeave = (event: DragEvent) => {
if (!isFileDrag(event)) return;
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setDropActive(false);
};
const onDrop = (event: DragEvent) => {
if (!isFileDrag(event)) return;
event.preventDefault();
depthRef.current = 0;
setDropActive(false);
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) onFilesRef.current(files);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || depthRef.current === 0) return;
depthRef.current = 0;
setDropActive(false);
};
const onPaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length === 0) return;
event.preventDefault();
onFilesRef.current(files);
};
window.addEventListener("dragenter", onDragEnter);
window.addEventListener("dragover", onDragOver);
window.addEventListener("dragleave", onDragLeave);
window.addEventListener("drop", onDrop);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("paste", onPaste);
return () => {
window.removeEventListener("dragenter", onDragEnter);
window.removeEventListener("dragover", onDragOver);
window.removeEventListener("dragleave", onDragLeave);
window.removeEventListener("drop", onDrop);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("paste", onPaste);
};
}, []);
return { dropActive };
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Pure helpers for the Dokumente vault surface: URL-facet parsing and
* serialisation (the page's filter state lives in the URL so every view is
* deep-linkable and back-button-safe), the API query-string builder, the
* month bucketing + row chunking the virtualized timeline renders from, the
* upload-response contract (including the HTTP-200 `meta.duplicate` path),
* and byte formatting. Kept out of the client components so they stay
* trivially unit-testable without a render harness.
*/
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
INBOUND_DOCUMENT_KINDS,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
/**
* Parse the vault's URL search params (`?q&kind&episode&year`) into the
* filter object. Unknown kinds are dropped (a hand-edited URL never 422s the
* page), `kind` accepts repeats AND comma-separated values, and the result is
* normalised (kinds sorted, empties omitted) so the same view always produces
* the same TanStack cache key.
*/
export function parseVaultSearchParams(
params: URLSearchParams,
): DocumentVaultFilters {
const filters: DocumentVaultFilters = {};
const q = params.get("q")?.trim();
if (q) filters.q = q.slice(0, 100);
const kinds = params
.getAll("kind")
.flatMap((v) => v.split(","))
.map((v) => v.trim())
.filter((v): v is InboundDocumentKindValue => KIND_SET.has(v));
if (kinds.length > 0) filters.kinds = [...new Set(kinds)].sort();
const episode = params.get("episode")?.trim();
if (episode) filters.episodeId = episode.slice(0, 40);
const yearRaw = params.get("year");
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
const year = Number(yearRaw);
if (year >= 1900 && year <= 9999) filters.year = year;
}
return filters;
}
/**
* Serialise the filter object back into the page URL's search string (no
* leading `?`; empty string for the default view). Inverse of
* `parseVaultSearchParams` — round-tripping is pinned by test so deep links
* from the illness page / labs (`?episode=`, `?kind=LAB_RESULT`) stay stable.
*/
export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
const sp = new URLSearchParams();
if (filters.q) sp.set("q", filters.q);
if (filters.kinds && filters.kinds.length > 0) {
sp.set("kind", [...filters.kinds].sort().join(","));
}
if (filters.episodeId) sp.set("episode", filters.episodeId);
if (filters.year !== undefined) sp.set("year", String(filters.year));
return sp.toString();
}
/** Count of active facets (search counts as one) for the clear-all pill. */
export function countActiveFilters(filters: DocumentVaultFilters): number {
let count = 0;
if (filters.q) count += 1;
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
if (filters.episodeId) count += 1;
if (filters.year !== undefined) count += 1;
return count;
}
/**
* Build the `/api/documents/inbound` query string from the active facets and
* a keyset cursor. The sort is pinned to the filing date, newest first — the
* timeline IS the sort order. The API takes `episodeId` (the page URL uses
* the shorter `episode`).
*/
export function buildVaultListApiSearch(
filters: DocumentVaultFilters,
cursor: string | null,
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
): string {
const sp = new URLSearchParams();
if (filters.q) sp.set("q", filters.q);
if (filters.kinds && filters.kinds.length > 0) {
sp.set("kind", filters.kinds.join(","));
}
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
if (filters.year !== undefined) sp.set("year", String(filters.year));
sp.set("sort", "documentDate");
sp.set("order", "desc");
sp.set("limit", String(limit));
if (cursor) sp.set("cursor", cursor);
return sp.toString();
}
/** The date a document files under — its user filing date, else upload day. */
export function documentDateKey(doc: InboundDocumentDto): string {
if (doc.documentDate) return doc.documentDate;
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
return doc.createdAt.slice(0, 10);
}
/** The YYYY-MM month bucket a document files under. */
export function documentMonthKey(doc: InboundDocumentDto): string {
return documentDateKey(doc).slice(0, 7);
}
/**
* One flat item in the virtualized timeline: a month section label or one
* grid row of up to `columns` documents. The virtualizer windows over this
* flat list so the mounted DOM stays bounded regardless of corpus size.
*/
export type TimelineItem =
| { type: "month"; key: string }
| { type: "row"; key: string; documents: InboundDocumentDto[] };
/**
* Bucket an already-sorted document list into consecutive YYYY-MM sections
* and chunk each section into rows of `columns` documents. Order is preserved
* exactly as received — the server stays the single source of truth for
* sorting; a month opens the first time its key appears.
*/
export function buildTimelineItems(
documents: InboundDocumentDto[],
columns: number,
): TimelineItem[] {
const cols = Math.max(1, columns);
const items: TimelineItem[] = [];
let month: string | null = null;
let row: InboundDocumentDto[] = [];
const flushRow = () => {
if (row.length > 0 && month !== null) {
items.push({ type: "row", key: `${month}:${row[0].id}`, documents: row });
row = [];
}
};
for (const doc of documents) {
const key = documentMonthKey(doc);
if (key !== month) {
flushRow();
month = key;
items.push({ type: "month", key });
}
row.push(doc);
if (row.length === cols) flushRow();
}
flushRow();
return items;
}
/**
* Format a YYYY-MM month key as a locale month label ("März 2026"). Handed
* over as a noon-UTC instant so the month never shifts under the display
* timezone.
*/
export function formatMonthLabel(key: string, locale: string): string {
if (!/^\d{4}-\d{2}$/.test(key)) return key;
return new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${key}-15T12:00:00.000Z`));
}
/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */
export function formatBytes(bytes: number, locale: string): string {
const units = ["B", "KB", "MB", "GB"] as const;
let value = Math.max(0, bytes);
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1;
return `${new Intl.NumberFormat(locale, {
maximumFractionDigits: digits,
}).format(value)} ${units[unit]}`;
}
/**
* Shift-click range selection over the timeline's visual order. Selects
* every id between the last plainly-toggled anchor and the target
* (inclusive) — ADDITIVE, matching file-manager convention (a range gesture
* never deselects). Falls back to a plain toggle when the anchor is gone
* (filtered away, deleted) or was never set.
*/
export function expandRangeSelection(
orderedIds: readonly string[],
selected: ReadonlySet<string>,
anchorId: string | null,
targetId: string,
): Set<string> {
const next = new Set(selected);
const anchorIndex = anchorId === null ? -1 : orderedIds.indexOf(anchorId);
const targetIndex = orderedIds.indexOf(targetId);
if (anchorIndex === -1 || targetIndex === -1) {
if (next.has(targetId)) {
next.delete(targetId);
} else {
next.add(targetId);
}
return next;
}
const [from, to] =
anchorIndex <= targetIndex
? [anchorIndex, targetIndex]
: [targetIndex, anchorIndex];
for (let i = from; i <= to; i += 1) {
next.add(orderedIds[i]);
}
return next;
}
// ─── Upload response contract (§3.2) ───────────────────────────────────────
/**
* The §3.2 failure reasons the upload/restore paths translate client-side,
* plus the transport-level fallbacks. `rateLimited` maps 429; `generic`
* covers everything unclassifiable (proxy-stripped bodies included).
*/
export type UploadFailureReason =
| "fileTooLarge"
| "quotaExceeded"
| "unsupportedType"
| "purged"
| "duplicateExists"
| "rateLimited"
| "generic";
export interface UploadFailure {
ok: false;
reason: UploadFailureReason;
/** §3.2 — present on `fileTooLarge`. */
maxFileBytes?: number;
/** §3.2 — present on `quotaExceeded`. */
quotaBytes?: number;
usedBytes?: number;
/** §3.2 — present on `duplicateExists` (restore-conflict shape). */
existingId?: string;
}
export interface UploadSuccess {
ok: true;
document: InboundDocumentDto;
/**
* §3.2 — a same-user re-upload is HTTP 200 with envelope-level
* `meta.duplicate: true` and the EXISTING live row as `data`. Not an
* error: the UI toasts "already stored" and highlights that row.
*/
duplicate: boolean;
}
export type UploadResult = UploadSuccess | UploadFailure;
const REASONS: ReadonlySet<string> = new Set([
"fileTooLarge",
"quotaExceeded",
"unsupportedType",
"purged",
"duplicateExists",
]);
function numberOrUndefined(v: unknown): number | undefined {
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
/** Map a `{ status, meta }` failure onto the translated-reason union. */
export function classifyUploadFailure(
status: number,
meta: Record<string, unknown> | undefined,
): UploadFailure {
const reason = typeof meta?.reason === "string" ? meta.reason : "";
if (REASONS.has(reason)) {
return {
ok: false,
reason: reason as UploadFailureReason,
maxFileBytes: numberOrUndefined(meta?.maxFileBytes),
quotaBytes: numberOrUndefined(meta?.quotaBytes),
usedBytes: numberOrUndefined(meta?.usedBytes),
existingId:
typeof meta?.existingId === "string" ? meta.existingId : undefined,
};
}
// A reverse proxy can strip the JSON body (and its `meta`); fall back to
// the HTTP status so size / type / rate-limit still classify.
if (status === 413) return { ok: false, reason: "fileTooLarge" };
if (status === 415) return { ok: false, reason: "unsupportedType" };
if (status === 429) return { ok: false, reason: "rateLimited" };
return { ok: false, reason: "generic" };
}
/**
* Parse a completed upload's raw response (status + body text) against the
* envelope contract. This is the ONE place the duplicate contract lives:
* unlike every other read (which unwraps `data` and ignores `meta`), the
* upload MUST also read the success envelope's `meta.duplicate` flag.
*/
export function parseUploadResponse(
status: number,
bodyText: string,
): UploadResult {
let envelope: {
data?: unknown;
meta?: Record<string, unknown>;
} | null = null;
try {
const parsed: unknown = JSON.parse(bodyText);
if (parsed && typeof parsed === "object") {
envelope = parsed as { data?: unknown; meta?: Record<string, unknown> };
}
} catch {
envelope = null;
}
if (status >= 200 && status < 300) {
const doc = envelope?.data;
if (doc && typeof doc === "object" && "id" in doc) {
return {
ok: true,
document: doc as InboundDocumentDto,
duplicate: envelope?.meta?.duplicate === true,
};
}
return { ok: false, reason: "generic" };
}
return classifyUploadFailure(status, envelope?.meta);
}
@@ -10,6 +10,7 @@
*/
import { useState } from "react";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -143,6 +144,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
<IllnessCorrelationCard episodeId={episodeId} />
)}
{/* Condition ⇄ documents linking, condition side: the episode's
linked documents plus link/upload entries into the vault. Renders
nothing when the documents module is off for this account. */}
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
{episode ? (
<>
<LogDaySheet
+17 -1
View File
@@ -36,6 +36,13 @@ const PUBLIC_PATHS = [
"/c/",
];
// Routes that opt into the wide content container (`max-w-screen-2xl`
// instead of the standard `max-w-screen-xl`). The shell stays the single
// owner of the page frame — a wide page never re-implements paddings or
// margins; it is listed here instead. Currently: the Dokumente vault,
// whose multi-column document timeline earns the extra width.
const WIDE_CONTENT_PATHS = ["/documents"];
export function AuthShell({
children,
demoMode = false,
@@ -69,6 +76,9 @@ export function AuthShell({
pathname.startsWith("/about") ||
pathname.startsWith("/c/");
const isAdminPage = pathname.startsWith("/admin");
const isWidePage = WIDE_CONTENT_PATHS.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
const isOnboardingPage = pathname === "/onboarding";
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
@@ -333,7 +343,13 @@ export function AuthShell({
the desktop bottom-6 anchor), so the last line of content
can always scroll out from under the floating button.
*/}
<div className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6">
<div
className={
isWidePage
? "mx-auto max-w-screen-2xl px-4 pt-6 pb-20 md:px-6"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
{children}
</div>
</main>
@@ -20,8 +20,15 @@
* compact instead of as a long always-expanded checklist.
*/
import Link from "next/link";
import { useId, useState } from "react";
import { ChevronDown, Download, FileText, Loader2 } from "lucide-react";
import {
ChevronDown,
Download,
FileText,
FolderOpen,
Loader2,
} from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -32,6 +39,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -117,6 +125,7 @@ function buildSelectionSections(s: SectionState) {
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
const [format, setFormat] = useState<ExportFormat>("pdf");
const [days, setDays] = useState<number>(90);
const [practiceName, setPracticeName] = useState("");
@@ -250,6 +259,20 @@ export function HealthRecordExportPanel() {
<option value="180">{t("settings.healthRecord.range180")}</option>
<option value="365">{t("settings.healthRecord.range365")}</option>
</NativeSelect>
{/* Entry point into the document vault for the report period
(navigation only — attaching documents to the PDF is a later
phase). Year granularity is what the vault's URL filters
offer; the current year covers the bulk of any range. Only
rendered when the documents module is enabled. */}
{user?.modules?.inboundDocuments ? (
<Link
href={`/documents?year=${new Date().getFullYear()}`}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FolderOpen className="size-3.5" aria-hidden />
{t("settings.healthRecord.documentsLink")}
</Link>
) : null}
</div>
</div>