mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,42 @@
|
||||
/**
|
||||
* Query keys — inbound clinical documents (v1.25, W-DOCS-IN): the document
|
||||
* list and a single document's review detail (document + staged facts).
|
||||
* Part of the centralized factory; aggregated in `./index.ts`. Any inbound
|
||||
* write (upload / edit / confirm / discard) invalidates the `["documents"]`
|
||||
* prefix so the list and the open review repaint in lockstep.
|
||||
* Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable
|
||||
* document list (search / category filter / sort, keyset-paginated) and a
|
||||
* single document's detail (document + staged facts). Part of the centralized
|
||||
* factory; aggregated in `./index.ts`. Any document write (upload / edit /
|
||||
* extract / confirm / discard) invalidates the `["documents"]` prefix so the
|
||||
* list and the open detail repaint in lockstep.
|
||||
*/
|
||||
import type {
|
||||
DocumentListSort,
|
||||
InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* The parameter set that distinguishes one library list view from another.
|
||||
* Lives in the key so two different filter/sort combinations never share a
|
||||
* cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a
|
||||
* bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }`
|
||||
* resolve to the same key.
|
||||
*/
|
||||
export interface DocumentListParams {
|
||||
q?: string;
|
||||
kind?: InboundDocumentKindValue;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort: DocumentListSort;
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
export const documentKeys = {
|
||||
documents: () => ["documents"] as const,
|
||||
inboundDocuments: () => ["documents", "inbound"] as const,
|
||||
/**
|
||||
* The parameterised library list (keyset-paginated via `useInfiniteQuery`).
|
||||
* The literal `"list"` segment keeps it distinct from a single-document key
|
||||
* (`inboundDocument(id)`); cuid ids never collide with it. Stays under the
|
||||
* `["documents", "inbound"]` prefix so prefix invalidation still reaches it.
|
||||
*/
|
||||
inboundDocumentList: (params: DocumentListParams) =>
|
||||
["documents", "inbound", "list", params] as const,
|
||||
inboundDocument: (id: string) => ["documents", "inbound", id] as const,
|
||||
};
|
||||
Reference in new issue
Block a user