Files
HealthLog/src/components/documents/sources/document-source-picker.tsx
T
Marc-André Bombeck ab3f21c58c refactor(ui): one Segmented control instead of four
The timeline's Segmented, the document sources' SegmentedChoice, the
medication detail's SegmentedToggle and the icon-only ViewToggle were
four implementations of the same control in two looks, two at 36 px on
a phone. ui/segmented.tsx replaces all four, plus the hand-rolled range
rows above the lab and custom metric charts, the mood dimension window
and the inventory quantity mode: a muted track with the chosen segment
raised, radio semantics with roving focus by default, pressed-button
semantics for view switches and form sheets, icon-only segments, and
44 px per segment on a coarse pointer. A test holds the definition to
that one module.
2026-10-20 13:15:29 +02:00

771 lines
26 KiB
TypeScript

"use client";
/**
* The document picker (#1038): search a connected Paperless-ngx or Papra by
* document name, narrow by tag and date, tick the documents to bring over,
* import them.
*
* Name first, because that is how the person looks for a letter: the search
* field opens focused and debounces into the source's own name search. Tag and
* date are filters beside it, not the entry point. Each result says what
* HealthLog already holds under that source key: already imported, deleted
* here (not offered again: a deletion was a decision), or new.
*
* The import runs one document per request, in order, so every row can show
* its own progress and result, and one failure does not hide the others. A
* refusal that will answer every following document the same way (the hourly
* upload allowance, a full vault, a refused token) stops the run and says so.
*
* Where it was opened from decides what an import does besides storing:
*
* - `link`: the record already exists (a condition's page), so the server
* files the document against it as part of the import;
* - `onImported`: a form that owns its selection (a visit, a vaccination)
* gets the new ids and adds them itself. Linking on the server there would
* be undone by the form's own save, which replaces its links.
*
* With either, a document already in HealthLog can be picked too: it is not
* stored twice, it is linked or handed to the form.
*/
import { useCalendarDate } from "@/hooks/use-calendar-date";
import {
useInfiniteQuery,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
Check,
CircleAlert,
CircleCheck,
Loader2,
Search,
SearchX,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { TagChip } from "@/components/ui/tag-chip";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import {
DOCUMENT_PICKER_MAX_SELECTION,
slugForSystem,
type DocumentImportOutcome,
type DocumentPickerLinkKind,
type DocumentPickerSystem,
type DocumentSourceImportDto,
type DocumentSourceResultDto,
type DocumentSourceSearchDto,
type DocumentSourceTagDto,
} from "@/lib/documents/sources/types";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
import { formatBytes } from "@/lib/format/bytes";
import {
errorCodeOf,
isStoppingError,
sourceErrorMessage,
} from "./source-errors";
import { Segmented } from "@/components/ui/segmented";
import { systemName } from "./use-document-sources";
import { InlineAction } from "@/components/ui/inline-action";
export interface DocumentPickerLink {
kind: DocumentPickerLinkKind;
id: string;
}
export interface DocumentSourcePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Connected systems, in the order the switch shows them. */
systems: DocumentPickerSystem[];
/** File the imported documents against this existing record. */
link?: DocumentPickerLink;
/** Document type for everything imported (default OTHER). */
kind?: InboundDocumentKindValue;
/** Hand the vault ids to a form that links them on its own save. */
onImported?: (documentIds: string[]) => void;
}
type RowRun =
| { state: "waiting" }
| { state: "working" }
| { state: DocumentImportOutcome; linked: boolean }
| { state: "failed"; message: string };
const SEARCH_DEBOUNCE_MS = 300;
const IMPORT_TIMEOUT_MS = 120_000;
export function DocumentSourcePicker({
open,
onOpenChange,
systems,
link,
kind,
onImported,
}: DocumentSourcePickerProps) {
const { t, locale } = useTranslations();
const calendarDate = useCalendarDate();
const queryClient = useQueryClient();
const [system, setSystem] = useState<DocumentPickerSystem | null>(null);
const active =
system && systems.includes(system) ? system : (systems[0] ?? null);
const slug = active ? slugForSystem(active) : null;
const [searchDraft, setSearchDraft] = useState("");
const [q, setQ] = useState("");
const [tag, setTag] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [selected, setSelected] = useState<Map<string, string>>(new Map());
const [runs, setRuns] = useState<Map<string, RowRun>>(new Map());
const [running, setRunning] = useState(false);
const [finished, setFinished] = useState(false);
const [stopMessage, setStopMessage] = useState<string | null>(null);
const [atMax, setAtMax] = useState(false);
/** The latest per-row result, for screen readers (aria-live below). */
const [announcement, setAnnouncement] = useState("");
// A new search or filter starts over: a selection made in one result list
// must not ride along unseen into another, and a finished run's result
// lines belong to the list they were made in.
const startOver = () => {
setSelected(new Map());
setRuns(new Map());
setFinished(false);
setStopMessage(null);
setAtMax(false);
};
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
const onSearchChange = (value: string) => {
setSearchDraft(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const next = value.trim();
if (next !== q) {
startOver();
setQ(next);
}
}, SEARCH_DEBOUNCE_MS);
};
const filterSetter =
(set: (value: string) => void) =>
(value: string): void => {
startOver();
set(value);
};
const reset = () => {
setSearchDraft("");
setQ("");
setTag("");
setFrom("");
setTo("");
startOver();
};
const switchSystem = (next: DocumentPickerSystem) => {
if (next === active || running) return;
reset();
setSystem(next);
};
const tags = useQuery({
queryKey: queryKeys.documentSourceTags(slug ?? ""),
enabled: open && slug !== null,
queryFn: () =>
apiGet<{ tags: DocumentSourceTagDto[] }>(
`/api/documents/sources/${slug}/tags`,
),
staleTime: 5 * 60_000,
});
const facets = {
q,
...(tag ? { tag } : {}),
...(from ? { from } : {}),
...(to ? { to } : {}),
};
const rangeInvalid = Boolean(from && to && from > to);
const search = useInfiniteQuery({
queryKey: queryKeys.documentSourceSearch(slug ?? "", facets),
enabled: open && slug !== null && !rangeInvalid,
initialPageParam: 1,
queryFn: ({ pageParam }) => {
const sp = new URLSearchParams({ page: String(pageParam) });
if (q) sp.set("q", q);
if (tag) sp.set("tag", tag);
if (from) sp.set("from", from);
if (to) sp.set("to", to);
return apiGet<DocumentSourceSearchDto>(
`/api/documents/sources/${slug}/search?${sp.toString()}`,
);
},
getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined),
staleTime: 30_000,
});
const results = useMemo(() => {
const seen = new Set<string>();
const out: DocumentSourceResultDto[] = [];
for (const page of search.data?.pages ?? []) {
for (const row of page.results) {
if (seen.has(row.sourceId)) continue;
seen.add(row.sourceId);
out.push(row);
}
}
return out;
}, [search.data]);
// An already imported document is worth picking only when the pick does
// something with it: link it, or hand it to the form.
const canTakeImported = Boolean(link || onImported);
const selectable = (row: DocumentSourceResultDto) =>
row.state === "new" || (row.state === "imported" && canTakeImported);
const toggle = (row: DocumentSourceResultDto) => {
if (running || finished || !selectable(row)) return;
const next = new Map(selected);
if (next.has(row.sourceId)) {
next.delete(row.sourceId);
setAtMax(false);
} else if (next.size < DOCUMENT_PICKER_MAX_SELECTION) {
next.set(row.sourceId, row.title);
} else {
// Said rather than silently ignored: the tick that did not appear.
setAtMax(true);
return;
}
setSelected(next);
};
async function runImport() {
if (!slug || selected.size === 0) return;
setRunning(true);
setStopMessage(null);
const order = [...selected.keys()];
setRuns(new Map(order.map((id) => [id, { state: "waiting" }])));
const handed: string[] = [];
const mark = (id: string, run: RowRun) => {
setRuns((prev) => new Map(prev).set(id, run));
const title = selected.get(id) ?? id;
const said = runLabel(t, run);
if (said) setAnnouncement(`${title}: ${said}`);
};
for (const sourceId of order) {
mark(sourceId, { state: "working" });
try {
const result = await apiPost<DocumentSourceImportDto>(
`/api/documents/sources/${slug}/import`,
{
sourceId,
...(kind ? { kind } : {}),
...(link ? { link } : {}),
},
// The server may take up to a minute to fetch a large file from
// the source; the default 15 s client timeout would abandon it.
{ signal: AbortSignal.timeout(IMPORT_TIMEOUT_MS) },
);
mark(sourceId, { state: result.outcome, linked: result.linked });
if (result.outcome !== "deleted" && result.documentId) {
handed.push(result.documentId);
}
} catch (err) {
const code = errorCodeOf(err);
const message = sourceErrorMessage(t, code);
mark(sourceId, { state: "failed", message });
if (isStoppingError(code)) {
setStopMessage(message);
break;
}
}
}
setRunning(false);
setFinished(true);
setSelected(new Map());
// The vault, its pickers and the record's own lists read documents
// through the `["documents"]` prefix; the picker's states through its own.
void invalidateKeys(queryClient, [
queryKeys.documents(),
queryKeys.documentSources(),
]);
if (handed.length > 0) onImported?.(handed);
}
const counts = useMemo(() => {
let imported = 0;
let present = 0;
let failed = 0;
for (const run of runs.values()) {
if (run.state === "imported") imported += 1;
else if (run.state === "duplicate" || run.state === "deleted")
present += 1;
else if (run.state === "failed") failed += 1;
}
return { imported, present, failed };
}, [runs]);
const name = active ? systemName(active) : "";
const hasFilters = Boolean(tag || from || to);
const footer = (
<div className="flex w-full items-center justify-between gap-3">
<p
className="text-muted-foreground min-w-0 text-xs"
data-slot="document-source-selection"
>
{finished
? null
: running
? t("documents.sourcePicker.importing")
: t("documents.sourcePicker.selectedCount", {
count: selected.size,
max: DOCUMENT_PICKER_MAX_SELECTION,
})}
</p>
{finished ? (
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={startOver}
data-slot="document-source-pick-more"
>
{t("documents.sourcePicker.pickMore")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => onOpenChange(false)}
>
{t("documents.sourcePicker.done")}
</Button>
</div>
) : (
<Button
type="button"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
disabled={running || selected.size === 0}
onClick={() => void runImport()}
data-slot="document-source-import"
>
{running ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("documents.sourcePicker.importAction", { count: selected.size })}
</Button>
)}
</div>
);
return (
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
// A run in progress finishes before the sheet can close; its rows
// are the only place the outcome is shown.
if (!next && running) return;
if (!next) reset();
onOpenChange(next);
}}
title={t("documents.sourcePicker.title", { name })}
description={t("documents.sourcePicker.description")}
contentWidth="lg"
footer={footer}
>
<div className="space-y-3" data-slot="document-source-picker">
{systems.length > 1 && active ? (
<Segmented
options={systems.map((system) => ({
value: system,
label: systemName(system),
}))}
value={active}
onChange={switchSystem}
label={t("documents.sourcePicker.sourceLabel")}
disabled={running}
className="self-start"
/>
) : null}
<div className="space-y-1.5">
<Label htmlFor="document-source-search">
{t("documents.sourcePicker.searchLabel")}
</Label>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
id="document-source-search"
type="search"
autoFocus
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.sourcePicker.searchPlaceholder")}
className="pl-9"
maxLength={200}
autoComplete="off"
data-slot="document-source-search"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="col-span-2 space-y-1.5 sm:col-span-1">
<Label htmlFor="document-source-tag">
{t("documents.sourcePicker.tagLabel")}
</Label>
<NativeSelect
id="document-source-tag"
value={tag}
onChange={(e) => filterSetter(setTag)(e.target.value)}
disabled={tags.isPending && !tags.isError}
>
<option value="">{t("documents.sourcePicker.tagAny")}</option>
{(tags.data?.tags ?? []).map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-source-from">
{t("documents.sourcePicker.fromLabel")}
</Label>
<DateField
id="document-source-from"
value={from}
onChange={filterSetter(setFrom)}
max={to || undefined}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-source-to">
{t("documents.sourcePicker.toLabel")}
</Label>
<DateField
id="document-source-to"
value={to}
onChange={filterSetter(setTo)}
min={from || undefined}
/>
</div>
</div>
{hasFilters ? (
<InlineAction
tone="muted"
onClick={() => {
startOver();
setTag("");
setFrom("");
setTo("");
}}
className="min-h-8"
>
<X className="size-3.5" aria-hidden />
{t("documents.sourcePicker.clearFilters")}
</InlineAction>
) : null}
{finished ? (
<div className="space-y-1">
<WrittenOutcomeLine
outcome={runOutcome(counts)}
message={t("documents.sourcePicker.summary", counts)}
testId="document-source-summary"
/>
<p className="text-muted-foreground text-xs">
{t("documents.sourcePicker.finishedHint")}
</p>
</div>
) : null}
{atMax ? (
<p
role="status"
className="text-muted-foreground text-xs"
data-slot="document-source-at-max"
>
{t("documents.sourcePicker.maxReached", {
max: DOCUMENT_PICKER_MAX_SELECTION,
})}
</p>
) : null}
{/* Each row's result as it lands, for a screen reader. */}
<div aria-live="polite" className="sr-only">
{announcement}
</div>
{stopMessage ? (
<p
role="alert"
className="text-destructive text-sm"
data-slot="document-source-stopped"
>
{t("documents.sourcePicker.stopped", { reason: stopMessage })}
</p>
) : null}
{rangeInvalid ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.sourcePicker.rangeInvalid")}
</p>
) : search.isPending ? (
<div className="space-y-2" data-slot="document-source-loading">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : search.isError ? (
<QueryErrorRow
error={search.error}
message={sourceErrorMessage(t, errorCodeOf(search.error))}
onRetry={() => void search.refetch()}
slot="document-source-error"
/>
) : results.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
icon={<SearchX className="size-6" aria-hidden />}
title={
q || hasFilters
? t("documents.sourcePicker.noMatch")
: t("documents.sourcePicker.empty", { name })
}
description={
q || hasFilters
? t("documents.sourcePicker.noMatchHint")
: undefined
}
/>
) : (
<ul
className="max-h-[45vh] space-y-1.5 overflow-y-auto overscroll-contain"
data-slot="document-source-results"
>
{results.map((row) => {
const on = selected.has(row.sourceId);
const run = runs.get(row.sourceId);
const allowed = selectable(row);
const meta = [
row.date ? calendarDate(row.date) : null,
row.sizeBytes !== null
? formatBytes(row.sizeBytes, locale)
: null,
]
.filter(Boolean)
.join(", ");
return (
<li key={row.sourceId}>
<button
type="button"
aria-pressed={on}
aria-disabled={!allowed || running || finished}
onClick={() => toggle(row)}
data-slot="document-source-result"
data-state={row.state}
className={cn(
"border-border flex min-h-11 w-full items-start gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
allowed && !running && !finished && "hover:bg-muted/50",
on && "border-primary/40 bg-primary/5",
// A row that just ran keeps full contrast: its result is
// the thing to read.
// After a run, the rows it did not touch step back:
// the ones with a result are what to read now.
((!allowed && !run) || (finished && !run)) &&
"opacity-50",
)}
>
<Check
className={cn(
"text-primary mt-0.5 size-4 shrink-0",
on ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="min-w-0 flex-1 space-y-1">
<span className="block truncate text-sm font-medium">
{row.title}
</span>
{meta ? (
<span className="text-muted-foreground block text-xs">
{meta}
</span>
) : null}
{row.tags.length > 0 ? (
<span className="flex flex-wrap gap-1">
{row.tags.slice(0, 6).map((name) => (
<TagChip key={name}>{name}</TagChip>
))}
</span>
) : null}
{run ? <RunLine run={run} /> : null}
</span>
{row.state !== "new" && !run ? (
<Badge variant="outline" className="shrink-0">
{row.state === "imported"
? t("documents.sourcePicker.stateImported")
: t("documents.sourcePicker.stateDeleted")}
</Badge>
) : null}
</button>
</li>
);
})}
</ul>
)}
{search.hasNextPage && !search.isError ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 w-full sm:min-h-9"
disabled={search.isFetchingNextPage}
onClick={() => void search.fetchNextPage()}
>
{search.isFetchingNextPage ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("documents.sourcePicker.loadMore")}
</Button>
) : null}
</div>
</ResponsiveSheet>
);
}
/** How the run went, in the shared outcome vocabulary. */
function runOutcome(counts: {
imported: number;
present: number;
failed: number;
}): WrittenOutcome {
const held = counts.imported + counts.present;
if (counts.failed === 0) return held > 0 ? "success" : "empty";
return held > 0 ? "partial" : "failed";
}
/** A row's result in words, or null while it has none yet. */
function runLabel(
t: (key: string, params?: Record<string, string | number>) => string,
run: RowRun,
): string | null {
switch (run.state) {
case "waiting":
case "working":
return null;
case "failed":
return run.message;
case "deleted":
return t("documents.sourcePicker.result.deleted");
case "imported":
return run.linked
? t("documents.sourcePicker.result.importedLinked")
: t("documents.sourcePicker.result.imported");
case "duplicate":
return run.linked
? t("documents.sourcePicker.result.duplicateLinked")
: t("documents.sourcePicker.result.duplicate");
}
}
/** One row's progress or result, under its title. */
function RunLine({ run }: { run: RowRun }) {
const { t } = useTranslations();
switch (run.state) {
case "waiting":
return (
<span className="text-muted-foreground block text-xs">
{t("documents.sourcePicker.result.waiting")}
</span>
);
case "working":
return (
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("documents.sourcePicker.result.working")}
</span>
);
case "failed":
return (
<span
className="text-destructive flex items-start gap-1.5 text-xs"
data-slot="document-source-run-failed"
>
<CircleAlert className="mt-px size-3.5 shrink-0" aria-hidden />
{run.message}
</span>
);
case "deleted":
return (
<span className="text-muted-foreground block text-xs">
{t("documents.sourcePicker.result.deleted")}
</span>
);
case "imported":
case "duplicate":
return (
<span
className="text-success flex items-center gap-1.5 text-xs"
data-slot="document-source-run-done"
>
<CircleCheck className="size-3.5 shrink-0" aria-hidden />
{run.state === "imported"
? run.linked
? t("documents.sourcePicker.result.importedLinked")
: t("documents.sourcePicker.result.imported")
: run.linked
? t("documents.sourcePicker.result.duplicateLinked")
: t("documents.sourcePicker.result.duplicate")}
</span>
);
}
}