mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'feat/vault-w4-qa' into release/v1.27.17
This commit is contained in:
commit
03058b0210
81 files changed
+10544
-3246
No files matched your search
@@ -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&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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user