fix(documents): reconcile library quality-of-life passes

- Reword the upload toasts to the store-only reality ("stored", not "read"),
  so the success message no longer claims AI ran and the panel it opens no
  longer contradicts it.
- Classify upload failures (too large / wrong type / rate-limited / invalid
  metadata) into specific messages; add a client-side 12 MB pre-check and show
  the accepted formats and size ceiling beside the file field.
- Clear filters, search, and sort on a successful upload so the new document is
  in the list the detail panel opens against, and add a "Clear filters" action
  on the no-results state.
- List approved facts read-only after review so the link between a document and
  the records it contributed stays visible instead of "Nothing left to review".
- Format date-group headers through the app's own date formatter, so they honour
  the user's date-format preference instead of a textual medium style.
- Stack the toolbar until md and let it wrap; wrap the fact badge row on narrow
  screens.
- Tie the disabled Approve to a plain-language reason, label the confidence
  percentage, and render it through the locale percent formatter.
- Treat a fully-confirmed re-extract (422) as the calm already-reviewed note,
  matching the partly-confirmed case.
- German: unify on "Angaben" for the extracted items and drop the anglicism.
This commit is contained in:
Marc-André Bombeck committed 2026-06-29 00:33:30 +02:00
1 parent 4702546cdb
commit e7683c9f54
10 files changed
+423 -95

No files matched your search

@@ -128,6 +128,8 @@ describe("<InboundDocumentsView> library", () => {
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", () => {
@@ -287,6 +289,41 @@ describe("<DocumentDetail>", () => {
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={() => {}} />,
@@ -4,7 +4,9 @@ import { ApiError } from "@/lib/api/api-fetch";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
buildDocumentListSearch,
classifyUploadError,
documentDateKey,
formatDateGroupLabel,
groupDocumentsByDate,
isAlreadyConfirmedError,
isProviderUnsupportedError,
@@ -126,13 +128,20 @@ describe("isProviderUnsupportedError", () => {
});
describe("isAlreadyConfirmedError", () => {
it("matches the extract 422 already-confirmed signal", () => {
const err = new ApiError("already confirmed", 422, {
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(
@@ -145,3 +154,50 @@ describe("isAlreadyConfirmedError", () => {
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("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");
});
});
@@ -67,7 +67,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentListParams } from "@/lib/query-keys/documents";
import {
@@ -84,10 +84,12 @@ import {
} from "@/lib/validations/inbound-documents";
import {
buildDocumentListSearch,
classifyUploadError,
formatDateGroupLabel,
groupDocumentsByDate,
isAlreadyConfirmedError,
isProviderUnsupportedError,
MAX_UPLOAD_BYTES,
} from "./library-utils";
type ListResponse = {
@@ -97,7 +99,8 @@ type ListResponse = {
type Decision = "approve" | "reject";
export function InboundDocumentsView() {
const { t, locale } = useTranslations();
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
@@ -155,6 +158,17 @@ export function InboundDocumentsView() {
filters.q || filters.kind || filters.from || filters.to,
);
// Reset search + every filter to the default view. Used by the no-results
// "Clear filters" affordance and after an upload (so the just-stored document
// is in the list the detail panel opens against).
const clearFilters = () => {
setQ("");
setDebouncedQ("");
setKindFilter("");
setFromDate("");
setToDate("");
};
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData();
@@ -170,6 +184,14 @@ export function InboundDocumentsView() {
},
onSuccess: (doc) => {
toast.success(t("documents.toast.uploaded"));
// Drop any active filter / search / sort so the just-stored document is
// in the list — otherwise selecting it opens a detail panel for a row the
// current filter excludes, and the user lands on a dead end. A fresh
// upload files under today, so the default documentDate-desc view shows it
// at the top.
clearFilters();
setSort("documentDate");
setOrder("desc");
void invalidateKeys(queryClient, [queryKeys.documents()]);
setSelectedId(doc.id);
setUploadTitle("");
@@ -177,7 +199,24 @@ export function InboundDocumentsView() {
setHasFile(false);
if (fileRef.current) fileRef.current.value = "";
},
onError: () => toast.error(t("documents.toast.uploadFailed")),
onError: (error) => {
switch (classifyUploadError(error)) {
case "tooLarge":
toast.error(t("documents.toast.uploadTooLarge"));
break;
case "fileType":
toast.error(t("documents.toast.uploadFileType"));
break;
case "rateLimited":
toast.error(t("documents.toast.uploadRateLimited"));
break;
case "invalidMetadata":
toast.error(t("documents.toast.uploadInvalidMetadata"));
break;
default:
toast.error(t("documents.toast.uploadFailed"));
}
},
});
return (
@@ -201,6 +240,27 @@ export function InboundDocumentsView() {
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* The one required field goes first and full-width, with the
accepted formats + size ceiling beside it (so the limit is known
before an upload fails). */}
<div className="flex flex-col gap-1 sm:col-span-2">
<Label htmlFor="doc-file">
{t("documents.upload.fileLabel")}
</Label>
<Input
id="doc-file"
ref={fileRef}
type="file"
required
accept="image/jpeg,image/png,image/webp,application/pdf"
aria-describedby="doc-file-hint"
disabled={upload.isPending}
onChange={(e) => setHasFile(Boolean(e.target.files?.length))}
/>
<p id="doc-file-hint" className="text-muted-foreground text-xs">
{t("documents.upload.accepts")}
</p>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="doc-title">
{t("documents.upload.titleLabel")}
@@ -244,25 +304,19 @@ export function InboundDocumentsView() {
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}
onChange={(e) => setHasFile(Boolean(e.target.files?.length))}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
const file = fileRef.current?.files?.[0];
if (file) upload.mutate(file);
if (!file) return;
// Reject an oversized file before it is sent, so the user gets
// the size message immediately instead of after a full upload.
if (file.size > MAX_UPLOAD_BYTES) {
toast.error(t("documents.toast.uploadTooLarge"));
return;
}
upload.mutate(file);
}}
disabled={upload.isPending || !hasFile}
>
@@ -289,10 +343,10 @@ export function InboundDocumentsView() {
{/* Toolbar — search / category filter / sort. */}
<div
className="flex flex-col gap-2 sm:flex-row sm:items-end"
className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-end"
data-slot="documents-toolbar"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Label htmlFor="doc-search">
{t("documents.toolbar.searchLabel")}
</Label>
@@ -394,11 +448,23 @@ export function InboundDocumentsView() {
))}
</div>
) : documents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{isFiltered
? t("documents.list.noResults")
: t("documents.list.empty")}
</p>
isFiltered ? (
<div
className="flex flex-col items-start gap-2"
data-slot="documents-no-results"
>
<p className="text-muted-foreground text-sm">
{t("documents.list.noResults")}
</p>
<Button variant="outline" size="sm" onClick={clearFilters}>
{t("documents.list.clearFilters")}
</Button>
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("documents.list.empty")}
</p>
)
) : (
<>
{groups.map((group) => (
@@ -407,7 +473,7 @@ export function InboundDocumentsView() {
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
data-slot="documents-date-group"
>
{formatDateGroupLabel(group.key, locale)}
{formatDateGroupLabel(group.key, format.date)}
</h2>
{group.documents.map((doc) => {
const expanded = selectedId === doc.id;
@@ -518,6 +584,7 @@ export function DocumentDetail({
regionId?: string;
}) {
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const [editOpen, setEditOpen] = useState(false);
@@ -593,6 +660,10 @@ export function DocumentDetail({
const doc = detail.data;
const pending = doc.facts.filter((f) => f.status === "PENDING");
// Facts already approved from this document and pushed to the structured
// stores — surfaced read-only below so the document ↔ committed-record link
// stays visible after review (instead of the panel going blank).
const approved = doc.facts.filter((f) => f.status === "APPROVED");
const decidedCount = Object.keys(decisions).length;
// Mirror the backend's hard guard: extraction is gone once the document is
// confirmed OR any fact on it has already been approved (re-extracting would
@@ -732,13 +803,36 @@ export function DocumentDetail({
: t("documents.review.confirm")}
</Button>
</>
) : (
) : approved.length === 0 ? (
<p className="text-muted-foreground text-sm">
{doc.factCount > 0
? t("documents.empty.noFacts")
: t("documents.detail.notExtracted")}
</p>
)}
) : null}
{approved.length > 0 ? (
<div className="flex flex-col gap-2" data-slot="documents-committed">
<Separator />
<p className="text-muted-foreground text-sm">
{t("documents.committed.title")}
</p>
<ul className="flex flex-col gap-1">
{approved.map((fact) => {
const when = factDate(fact);
return (
<li
key={fact.id}
className="text-muted-foreground truncate text-sm"
>
{factSummary(fact)}
{when ? ` · ${format.date(`${when}T12:00:00.000Z`)}` : ""}
</li>
);
})}
</ul>
</div>
) : null}
</CardContent>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
@@ -890,6 +984,7 @@ function FactCard({
onDecision: (d: Decision | null) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
@@ -912,7 +1007,7 @@ function FactCard({
<div className="rounded-md border p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{t(`documents.factType.${fact.factType}`)}
</Badge>
@@ -927,8 +1022,14 @@ function FactCard({
{t("documents.review.needsReview")}
</Badge>
) : null}
<span className="text-muted-foreground text-xs">
{Math.round(fact.confidence * 100)}%
<span
className="text-muted-foreground text-xs"
title={t("documents.review.confidenceLabel")}
>
<span className="sr-only">
{t("documents.review.confidenceLabel")}:{" "}
</span>
{format.percent(fact.confidence)}
</span>
</div>
<p className="mt-1 truncate text-sm font-medium">{summary}</p>
@@ -937,6 +1038,13 @@ function FactCard({
“{fact.provenance.sourceText}”
</p>
) : null}
{fact.needsReview ? (
// Ties the disabled Approve button to the "Needs review" badge: a
// low-confidence fact must be edited before it can be approved.
<p className="text-muted-foreground mt-1 text-xs">
{t("documents.review.needsReviewHint")}
</p>
) : null}
</div>
<div className="flex shrink-0 gap-1">
<Button
@@ -944,6 +1052,11 @@ function FactCard({
size="sm"
className="min-h-11 sm:min-h-9"
disabled={fact.needsReview}
title={
fact.needsReview
? t("documents.review.needsReviewHint")
: undefined
}
onClick={() =>
onDecision(decision === "approve" ? null : "approve")
}
@@ -1148,3 +1261,14 @@ function factSummary(fact: ExtractedFactDto): string {
const d = fact.data as ConditionFactData;
return d.label;
}
/** The effective / onset date a staged fact carries, if any (YYYY-MM-DD). */
function factDate(fact: ExtractedFactDto): string | null {
if (fact.factType === "CONDITION") {
return (fact.data as ConditionFactData).onsetDate;
}
if (fact.factType === "OBSERVATION") {
return (fact.data as ObservationFactData).effectiveDate;
}
return (fact.data as MedicationStatementFactData).effectiveDate;
}
+61 -16
View File
@@ -83,27 +83,72 @@ export function isProviderUnsupportedError(error: unknown): boolean {
}
/**
* True when an error is the extract route's 422 "this document has already been
* confirmed" signal. Re-extraction is refused server-side once any fact has
* been committed; the UI surfaces this as a calm inline note rather than a hard
* error toast, and hides the Extract control to match.
* 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 (
error instanceof ApiError &&
error.meta?.errorCode === "documents.inbound.alreadyPartlyConfirmed"
code === "documents.inbound.alreadyPartlyConfirmed" ||
code === "documents.inbound.alreadyConfirmed"
);
}
/** 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;
/**
* 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`);
}