feat(documents): build the Documents library UI

Evolve the inbound-documents screen into a real document library on top of
the store-first backend.

- Upload stays store-only and always works: the form now files a document
  with an optional title, category, and document date in one step. Extraction
  is a separate, explicit action.
- List becomes an organized library: documents grouped by date, with a title
  (filename fallback), category and status badges, and pending/fact counts. A
  toolbar adds title/filename search, a category filter, and a sort control
  (document date / date added / title, asc/desc). Keyset pagination loads more
  via the returned cursor through useInfiniteQuery.
- Detail panel: view or download the original, edit metadata (rename,
  recategorise, set the document date), optionally run extraction, then the
  existing review-then-confirm flow. When no scan provider is configured,
  extraction surfaces a calm inline note instead of a hard error; the stored
  document is untouched. Discard stays.
- Add a parameterised list key to the query-key factory so each filter/sort
  combination keeps its own cache bucket while staying under the documents
  prefix for invalidation.
- Rename the surface to "Documents" across all six locales (title, subtitle,
  module label/description) and add the new toolbar, edit, extract, and
  date-group strings. Route, model, and module key stay internal.

Add component and unit coverage for the grouped list, the detail/review
surface, the metadata edit form, the provider-unsupported note, and the
search-param/grouping/error-classification helpers.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 23:26:01 +02:00
1 parent 02df0d1f0e
commit c6a7de8f2a
11 files changed
+1606 -284

No files matched your search

@@ -0,0 +1,235 @@
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 {
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");
});
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"');
});
});
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, not a hard error", () => {
const html = render(<ProviderUnsupportedNote />);
expect(html).toContain('data-slot="documents-provider-note"');
expect(html).toContain("Configure a document-scan provider");
});
});
@@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildDocumentListSearch,
documentDateKey,
groupDocumentsByDate,
isProviderUnsupportedError,
} from "../library-utils";
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);
});
});
@@ -1,22 +1,55 @@
"use client";
/**
* v1.25.0 (W-DOCS-IN) — inbound clinical documents review-then-confirm UI.
* v1.25 — Documents library.
*
* Upload a doctor report / discharge letter, then review the STRUCTURED FACTS
* the OCR/vision provider transcribed before any of them reaches the
* structured stores. The screen reproduces what the document stated — it never
* interprets. A low-confidence fact fails closed: it cannot be approved until
* the user edits it.
* A self-hoster files any clinical document (doctor report, discharge letter,
* lab result, imaging, prescription, …). Upload is STORE-ONLY and always works,
* even with no AI provider configured — a file is filed with optional title /
* category / filing date. The library groups documents by date, with search, a
* category filter, sort, and keyset pagination.
*
* Each document opens a detail panel: view / download the original, edit its
* metadata, optionally run AI extraction (the explicit, separate enhancement),
* then review-then-confirm the STRUCTURED FACTS the provider transcribed before
* any reaches the structured stores. The screen reproduces what the document
* stated — it never interprets. A low-confidence fact fails closed: it cannot
* be approved until the user edits it. Absent a provider, extraction surfaces a
* calm inline note (configure one) — never a hard error; the stored document is
* untouched.
*/
import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { ExternalLink, FileScan, Loader2, Upload } from "lucide-react";
import {
ArrowDownAZ,
ArrowUpAZ,
ExternalLink,
FileScan,
Info,
Loader2,
Pencil,
Search,
Sparkles,
Upload,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
@@ -25,9 +58,12 @@ import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentListParams } from "@/lib/query-keys/documents";
import {
DOCUMENT_LIST_SORTS,
INBOUND_DOCUMENT_KINDS,
type ConditionFactData,
type DocumentListSort,
type ExtractedFactDto,
type InboundDocumentDetailDto,
type InboundDocumentDto,
@@ -35,28 +71,76 @@ import {
type MedicationStatementFactData,
type ObservationFactData,
} from "@/lib/validations/inbound-documents";
import {
buildDocumentListSearch,
formatDateGroupLabel,
groupDocumentsByDate,
isProviderUnsupportedError,
} from "./library-utils";
type ListResponse = { documents: InboundDocumentDto[] };
type ListResponse = {
documents: InboundDocumentDto[];
nextCursor: string | null;
};
type Decision = "approve" | "reject";
export function InboundDocumentsView() {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const queryClient = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const [kind, setKind] = useState<InboundDocumentKindValue>("DOCTOR_REPORT");
// Upload form metadata (filed with the document immediately).
const [uploadTitle, setUploadTitle] = useState("");
const [uploadKind, setUploadKind] =
useState<InboundDocumentKindValue>("DOCTOR_REPORT");
const [uploadDate, setUploadDate] = useState("");
// Library toolbar state.
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
const [kindFilter, setKindFilter] = useState<"" | InboundDocumentKindValue>(
"",
);
const [sort, setSort] = useState<DocumentListSort>("documentDate");
const [order, setOrder] = useState<"asc" | "desc">("desc");
const [selectedId, setSelectedId] = useState<string | null>(null);
const list = useQuery({
queryKey: queryKeys.inboundDocuments(),
queryFn: () =>
apiFetch<ListResponse>("/api/documents/inbound", { method: "GET" }),
// Debounce the search box so a query fires per pause, not per keystroke.
useEffect(() => {
const handle = setTimeout(() => setDebouncedQ(q), 300);
return () => clearTimeout(handle);
}, [q]);
const filters: DocumentListParams = {
q: debouncedQ.trim() || undefined,
kind: kindFilter || undefined,
sort,
order,
};
const list = useInfiniteQuery({
queryKey: queryKeys.inboundDocumentList(filters),
queryFn: ({ pageParam }) =>
apiFetch<ListResponse>(
`/api/documents/inbound?${buildDocumentListSearch(filters, pageParam)}`,
{ method: "GET" },
),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const documents = list.data?.pages.flatMap((p) => p.documents) ?? [];
const groups = groupDocumentsByDate(documents);
const isFiltered = Boolean(filters.q || filters.kind);
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
fd.append("file", file);
fd.append("kind", kind);
fd.append("kind", uploadKind);
if (uploadTitle.trim()) fd.append("title", uploadTitle.trim());
if (uploadDate) fd.append("documentDate", uploadDate);
return apiFetch<InboundDocumentDto>("/api/documents/inbound", {
method: "POST",
body: fd,
@@ -67,6 +151,8 @@ export function InboundDocumentsView() {
toast.success(t("documents.toast.uploaded"));
void invalidateKeys(queryClient, [queryKeys.documents()]);
setSelectedId(doc.id);
setUploadTitle("");
setUploadDate("");
if (fileRef.current) fileRef.current.value = "";
},
onError: () => toast.error(t("documents.toast.uploadFailed")),
@@ -84,112 +170,287 @@ export function InboundDocumentsView() {
</div>
</header>
{/* Upload */}
{/* Upload — store-only, always available. */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("documents.upload.label")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-col gap-1">
<Label htmlFor="doc-kind">{t("documents.upload.kindLabel")}</Label>
<NativeSelect
id="doc-kind"
value={kind}
onChange={(e) =>
setKind(e.target.value as InboundDocumentKindValue)
}
>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<Input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="max-w-xs"
aria-label={t("documents.upload.fileLabel")}
disabled={upload.isPending}
/>
<Button
onClick={() => {
const file = fileRef.current?.files?.[0];
if (file) upload.mutate(file);
}}
disabled={upload.isPending}
>
{upload.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="doc-title">
{t("documents.upload.titleLabel")}
</Label>
<Input
id="doc-title"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder={t("documents.upload.titlePlaceholder")}
disabled={upload.isPending}
/>
) : (
<Upload className="h-4 w-4" aria-hidden />
)}
{upload.isPending
? t("documents.upload.uploading")
: t("documents.upload.button")}
</Button>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-kind">
{t("documents.upload.kindLabel")}
</Label>
<NativeSelect
id="doc-kind"
value={uploadKind}
onChange={(e) =>
setUploadKind(e.target.value as InboundDocumentKindValue)
}
disabled={upload.isPending}
>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-date">
{t("documents.upload.dateLabel")}
</Label>
<Input
id="doc-date"
type="date"
value={uploadDate}
onChange={(e) => setUploadDate(e.target.value)}
disabled={upload.isPending}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-file">
{t("documents.upload.fileLabel")}
</Label>
<Input
id="doc-file"
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
disabled={upload.isPending}
/>
</div>
</div>
<div>
<Button
onClick={() => {
const file = fileRef.current?.files?.[0];
if (file) upload.mutate(file);
}}
disabled={upload.isPending}
>
{upload.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Upload className="h-4 w-4" aria-hidden />
)}
{upload.isPending
? t("documents.upload.uploading")
: t("documents.upload.button")}
</Button>
</div>
</CardContent>
</Card>
{/* Document list */}
<section className="flex flex-col gap-2">
{/* Toolbar — search / category filter / sort. */}
<div
className="flex flex-col gap-2 sm:flex-row sm:items-end"
data-slot="documents-toolbar"
>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="doc-search">
{t("documents.toolbar.searchLabel")}
</Label>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2"
aria-hidden
/>
<Input
id="doc-search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("documents.toolbar.searchPlaceholder")}
className="pl-8"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-kind-filter">
{t("documents.toolbar.kindFilterLabel")}
</Label>
<NativeSelect
id="doc-kind-filter"
value={kindFilter}
onChange={(e) =>
setKindFilter(e.target.value as "" | InboundDocumentKindValue)
}
>
<option value="">{t("documents.toolbar.allKinds")}</option>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-sort">{t("documents.toolbar.sortLabel")}</Label>
<div className="flex items-center gap-1">
<NativeSelect
id="doc-sort"
value={sort}
onChange={(e) => setSort(e.target.value as DocumentListSort)}
>
{DOCUMENT_LIST_SORTS.map((s) => (
<option key={s} value={s}>
{t(`documents.toolbar.sort.${s}`)}
</option>
))}
</NativeSelect>
<Button
variant="outline"
size="icon"
aria-label={t(
order === "asc"
? "documents.toolbar.orderAsc"
: "documents.toolbar.orderDesc",
)}
onClick={() => setOrder((o) => (o === "asc" ? "desc" : "asc"))}
>
{order === "asc" ? (
<ArrowUpAZ className="h-4 w-4" aria-hidden />
) : (
<ArrowDownAZ className="h-4 w-4" aria-hidden />
)}
</Button>
</div>
</div>
</div>
{/* Library list — grouped by date, keyset-paginated. */}
<section className="flex flex-col gap-4" data-slot="documents-library">
{list.isLoading ? (
// Skeleton rows (not a centered spinner) so the resolved list lands
// in place without a layout jump — the app's loading convention.
<>
<div className="flex flex-col gap-2" data-slot="documents-loading">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
<Skeleton key={i} className="h-12 w-full rounded-md" />
))}
</>
) : !list.data || list.data.documents.length === 0 ? (
</div>
) : documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.list.empty")}
{isFiltered
? t("documents.list.noResults")
: t("documents.list.empty")}
</p>
) : (
list.data.documents.map((doc) => (
<button
key={doc.id}
type="button"
aria-expanded={selectedId === doc.id}
onClick={() =>
setSelectedId(selectedId === doc.id ? null : doc.id)
}
className="hover:bg-accent flex min-h-11 items-center justify-between rounded-md border px-3 py-2 text-left text-sm"
>
<span className="truncate">
{doc.filename ?? t(`documents.kind.${doc.kind}`)}
</span>
<span className="flex items-center gap-2">
<Badge variant="secondary">
{t(`documents.status.${doc.status}`)}
</Badge>
{doc.pendingCount > 0 ? (
<Badge variant="outline">{doc.pendingCount}</Badge>
<>
{groups.map((group) => (
<div key={group.key} className="flex flex-col gap-2">
<h2
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
data-slot="documents-date-group"
>
{formatDateGroupLabel(group.key, locale)}
</h2>
{group.documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
expanded={selectedId === doc.id}
onToggle={() =>
setSelectedId(selectedId === doc.id ? null : doc.id)
}
/>
))}
{selectedId &&
group.documents.some((d) => d.id === selectedId) ? (
<DocumentDetail
documentId={selectedId}
onClosed={() => setSelectedId(null)}
/>
) : null}
</span>
</button>
))
</div>
))}
{list.hasNextPage ? (
<Button
variant="outline"
onClick={() => void list.fetchNextPage()}
disabled={list.isFetchingNextPage}
>
{list.isFetchingNextPage ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{list.isFetchingNextPage
? t("documents.list.loadingMore")
: t("documents.list.loadMore")}
</Button>
) : null}
</>
)}
</section>
{selectedId ? (
<DocumentReview
documentId={selectedId}
onClosed={() => setSelectedId(null)}
/>
) : null}
</div>
);
}
function DocumentReview({
/** A single library row — title (or filename), kind + status badges, counts. */
function DocumentRow({
doc,
expanded,
onToggle,
}: {
doc: InboundDocumentDto;
expanded: boolean;
onToggle: () => void;
}) {
const { t } = useTranslations();
const display = doc.title ?? doc.filename ?? t(`documents.kind.${doc.kind}`);
return (
<button
type="button"
aria-expanded={expanded}
onClick={onToggle}
data-slot="documents-row"
className="hover:bg-accent flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm"
>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{display}</span>
<span className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(`documents.kind.${doc.kind}`)}
</Badge>
<Badge variant="secondary" className="text-[10px]">
{t(`documents.status.${doc.status}`)}
</Badge>
</span>
</span>
{doc.pendingCount > 0 ? (
<Badge variant="outline" className="shrink-0">
{t("documents.list.pending", { count: doc.pendingCount })}
</Badge>
) : doc.factCount > 0 ? (
<Badge variant="secondary" className="shrink-0">
{t("documents.list.facts", { count: doc.factCount })}
</Badge>
) : null}
</button>
);
}
export function DocumentDetail({
documentId,
onClosed,
}: {
@@ -199,6 +460,7 @@ function DocumentReview({
const { t } = useTranslations();
const queryClient = useQueryClient();
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const [editOpen, setEditOpen] = useState(false);
const detail = useQuery({
queryKey: queryKeys.inboundDocument(documentId),
@@ -209,6 +471,23 @@ function DocumentReview({
),
});
const extract = useMutation({
// Empty body → vision mode over the stored original.
mutationFn: () =>
apiPost(`/api/documents/inbound/${documentId}/extract`, undefined),
onSuccess: () => {
toast.success(t("documents.toast.extracted"));
void invalidateKeys(queryClient, [queryKeys.documents()]);
},
onError: (err) => {
// A missing provider is not an error the user can fix here — surface the
// calm inline note (rendered below) instead of an alarming toast.
if (!isProviderUnsupportedError(err)) {
toast.error(t("documents.toast.extractFailed"));
}
},
});
const confirm = useMutation({
mutationFn: () =>
apiPost(`/api/documents/inbound/${documentId}/confirm`, {
@@ -250,65 +529,93 @@ function DocumentReview({
}
if (!detail.data) return null;
const pending = detail.data.facts.filter((f) => f.status === "PENDING");
const doc = detail.data;
const pending = doc.facts.filter((f) => f.status === "PENDING");
const decidedCount = Object.keys(decisions).length;
const canExtract = doc.status !== "CONFIRMED";
const showProviderNote = isProviderUnsupportedError(extract.error);
return (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">
{t("documents.review.title")}
</CardTitle>
<div className="flex items-center gap-1">
{/* View the original uploaded document. The route is same-origin and
cookie-authenticated, so a new tab carries the session; a PDF /
image renders inline, other types download. */}
<Card data-slot="documents-detail">
<CardHeader className="flex-col items-start gap-2 space-y-0">
<CardTitle className="text-base">{doc.title ?? doc.filename}</CardTitle>
<div className="flex flex-wrap items-center gap-1">
<Button asChild variant="ghost" size="sm">
{/* Same-origin, cookie-authenticated: a new tab carries the
session; a PDF / image renders inline, other types download. */}
<a
href={`/api/documents/inbound/${documentId}/original`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" aria-hidden />
{t("documents.review.viewOriginal")}
{t("documents.detail.viewOriginal")}
</a>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setEditOpen(true)}
data-slot="documents-edit-trigger"
>
<Pencil className="h-4 w-4" aria-hidden />
{t("documents.detail.edit")}
</Button>
{canExtract ? (
<Button
variant="ghost"
size="sm"
onClick={() => extract.mutate()}
disabled={extract.isPending}
data-slot="documents-extract"
>
{extract.isPending ? (
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Sparkles className="h-4 w-4" aria-hidden />
)}
{extract.isPending
? t("documents.detail.extracting")
: t("documents.detail.extract")}
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => discard.mutate()}
disabled={discard.isPending}
>
{t("documents.review.discard")}
{t("documents.detail.discard")}
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{pending.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("documents.empty.noFacts")}
</p>
) : (
pending.map((fact) => (
<FactCard
key={fact.id}
documentId={documentId}
fact={fact}
decision={decisions[fact.id]}
onDecision={(d) =>
setDecisions((prev) => {
const next = { ...prev };
if (d === null) delete next[fact.id];
else next[fact.id] = d;
return next;
})
}
/>
))
)}
{showProviderNote ? <ProviderUnsupportedNote /> : null}
{pending.length > 0 ? (
<>
<p className="text-muted-foreground text-sm">
{t("documents.review.title")}
</p>
{pending.map((fact) => (
<FactCard
key={fact.id}
documentId={documentId}
fact={fact}
decision={decisions[fact.id]}
onDecision={(d) =>
setDecisions((prev) => {
const next = { ...prev };
if (d === null) delete next[fact.id];
else next[fact.id] = d;
return next;
})
}
/>
))}
<Separator />
<Button
onClick={() => confirm.mutate()}
@@ -319,12 +626,127 @@ function DocumentReview({
: t("documents.review.confirm")}
</Button>
</>
) : null}
) : (
<p className="text-muted-foreground text-sm">
{doc.factCount > 0
? t("documents.empty.noFacts")
: t("documents.detail.notExtracted")}
</p>
)}
</CardContent>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("documents.edit.title")}</DialogTitle>
<DialogDescription>
{t("documents.edit.subtitle")}
</DialogDescription>
</DialogHeader>
<DocumentMetaEditForm
doc={doc}
onSaved={() => setEditOpen(false)}
onCancel={() => setEditOpen(false)}
/>
</DialogContent>
</Dialog>
</Card>
);
}
/** The calm inline note shown when extraction needs a provider that's absent. */
export function ProviderUnsupportedNote() {
const { t } = useTranslations();
return (
<div
className="flex items-start gap-2 rounded-md border border-sky-300 bg-sky-50 p-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-200"
data-slot="documents-provider-note"
>
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<p>{t("documents.note.providerUnsupported")}</p>
</div>
);
}
/** Metadata edit (rename / recategorise / set the filing date). PATCHes. */
export function DocumentMetaEditForm({
doc,
onSaved,
onCancel,
}: {
doc: InboundDocumentDto;
onSaved: () => void;
onCancel: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [title, setTitle] = useState(doc.title ?? "");
const [kind, setKind] = useState<InboundDocumentKindValue>(doc.kind);
const [date, setDate] = useState(doc.documentDate ?? "");
const save = useMutation({
mutationFn: () =>
apiPatch(`/api/documents/inbound/${doc.id}`, {
title: title.trim() === "" ? null : title.trim(),
kind,
documentDate: date === "" ? null : date,
}),
onSuccess: () => {
toast.success(t("documents.toast.updated"));
void invalidateKeys(queryClient, [queryKeys.documents()]);
onSaved();
},
onError: () => toast.error(t("documents.toast.updateFailed")),
});
return (
<div className="flex flex-col gap-3" data-slot="documents-edit-form">
<div className="flex flex-col gap-1">
<Label htmlFor="edit-doc-title">{t("documents.edit.titleLabel")}</Label>
<Input
id="edit-doc-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("documents.upload.titlePlaceholder")}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="edit-doc-kind">{t("documents.edit.kindLabel")}</Label>
<NativeSelect
id="edit-doc-kind"
value={kind}
onChange={(e) => setKind(e.target.value as InboundDocumentKindValue)}
>
{INBOUND_DOCUMENT_KINDS.map((k) => (
<option key={k} value={k}>
{t(`documents.kind.${k}`)}
</option>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="edit-doc-date">{t("documents.edit.dateLabel")}</Label>
<Input
id="edit-doc-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={save.isPending}>
{t("documents.edit.cancel")}
</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending
? t("documents.edit.saving")
: t("documents.edit.save")}
</Button>
</DialogFooter>
</div>
);
}
function FactCard({
documentId,
fact,
+96
View File
@@ -0,0 +1,96 @@
/**
* 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"
);
}
/** Format a YYYY-MM-DD group key into a locale-aware medium date label. */
export function formatDateGroupLabel(key: string, locale: string): string {
const date = new Date(`${key}T00:00:00`);
if (Number.isNaN(date.getTime())) return key;
try {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
date,
);
} catch {
return key;
}
}