feat(links): searchable, grouped entity link picker for visits and shots

The visit form's document / lab / condition link blocks and the
vaccination form's document link were flat lists capped at 25 rows with
no search: 30 lab values arrived undifferentiated and anything past the
cap was unreachable. Replace them with one shared picker — an inline
summary of removable chips over an add button, and a sheet that searches,
groups and multi-selects. Lab results gather under their panel and sample
date with a select-all per group, documents gather by month, illnesses
stay flat. The picker raises only the fetch limit; the server link cap
stays authoritative.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 07:01:15 +02:00
1 parent 40e53b14d6
commit 089c74cf23
11 files changed
+659 -191

No files matched your search

@@ -2,121 +2,39 @@
/**
* The visit form's three optional link blocks: documents, lab results,
* conditions.
* conditions. Each is the shared {@link EntityLinkPicker} — an inline summary
* (removable chips + an add button) over a searchable, grouped sheet.
*
* **The gate blanks the block, it does not post-filter it.** A module the
* account turned off leaves nothing behind here — no heading, no empty list,
* no "0 selected". An empty picker for a switched-off module advertises a
* feature that is not there and invites a person to wonder what is wrong. This
* is the same shape the dashboard snapshot uses for a disabled module.
* feature that is not there. This is the same shape the dashboard snapshot
* uses for a disabled module.
*
* The encounter itself carries NO module gate. A visit is core, like the
* checkups page it lives on; what it may point AT follows the modules that own
* those things, which is why the gate lives here and not on the route.
*
* Nothing in this block can block a save. Every list starts empty and stays
* valid empty.
* valid empty. The picker raises only the FETCH limit so grouping has enough
* to group; the link cap stays authoritative on the server.
*/
import { useQuery } from "@tanstack/react-query";
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { ModuleKey } from "@/lib/modules/registry";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
EntityLinkPicker,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
/** How many candidates one block offers before the person must use the sheet. */
const OPTION_LIMIT = 25;
interface Option {
id: string;
label: string;
/** ISO-8601, or null for a target with no date of its own. */
date: string | null;
}
function LinkBlock({
icon: Icon,
title,
options,
selected,
onToggle,
pending,
slot,
}: {
icon: LucideIcon;
title: string;
options: Option[];
selected: string[];
onToggle: (id: string) => void;
pending: boolean;
slot: string;
}) {
const { t } = useTranslations();
const format = useFormatters();
return (
<div className="space-y-2" data-slot={slot}>
<div className="flex items-center gap-2">
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
<span className="text-sm font-medium">{title}</span>
{selected.length > 0 ? (
<Badge variant="outline" className="ml-auto">
{selected.length}
</Badge>
) : null}
</div>
{pending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t("encounters.form.linkNothingToOffer")}
</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
{options.map((option) => {
const on = selected.includes(option.id);
return (
<li key={option.id}>
<button
type="button"
aria-pressed={on}
data-slot={`${slot}-option`}
onClick={() => onToggle(option.id)}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
on && "border-primary/40 bg-primary/5",
)}
>
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
{option.label}
</span>
{option.date ? (
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(option.date)}
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
function toggle(list: string[], id: string): string[] {
return list.includes(id)
? list.filter((entry) => entry !== id)
: [...list, id];
}
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
const PICKER_FETCH_LIMIT = 200;
interface LabListPage {
results: Array<{
@@ -149,6 +67,7 @@ export function EncounterLinkPickers({
}) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const documentsOn = modules?.inboundDocuments === true;
const labsOn = modules?.labs === true;
@@ -159,7 +78,7 @@ export function EncounterLinkPickers({
enabled: documentsOn,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
),
});
@@ -175,7 +94,7 @@ export function EncounterLinkPickers({
enabled: labsOn,
queryFn: () =>
apiGet<LabListPage>(
`/api/labs?limit=${OPTION_LIMIT}&offset=0&sortDir=desc`,
`/api/labs?limit=${PICKER_FETCH_LIMIT}&offset=0&sortDir=desc`,
),
});
@@ -184,63 +103,100 @@ export function EncounterLinkPickers({
enabled: illnessOn,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=true&limit=${OPTION_LIMIT}`,
`/api/illness/episodes?includeResolved=true&limit=${PICKER_FETCH_LIMIT}`,
),
});
if (!documentsOn && !labsOn && !illnessOn) return null;
// Documents group by month; the caller localizes the heading.
const documentOptions: EntityLinkOption[] = (
documents.data?.documents ?? []
).map((doc) => {
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
return {
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
dateLabel: date ? format.date(date) : null,
group: date
? {
key: date.slice(0, 7),
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
}
: null,
};
});
// Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte
// underneath. A visit links the whole panel from a day in one "select all".
const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map(
(result) => {
const dayLabel = format.date(result.takenAt);
const groupLabel = result.panel
? `${result.panel} · ${dayLabel}`
: dayLabel;
return {
id: result.id,
label: result.analyte,
dateLabel: dayLabel,
group: {
key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`,
label: groupLabel,
},
};
},
);
// Episodes stay flat (a bounded list), newest onset first from the route.
const episodeOptions: EntityLinkOption[] = (episodes.data ?? []).map(
(episode) => ({
id: episode.id,
label: episode.label,
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
}),
);
return (
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
{documentsOn ? (
<LinkBlock
<EntityLinkPicker
icon={FolderOpen}
title={t("encounters.form.linkDocuments")}
slot="encounter-link-documents"
pending={documents.isPending}
selected={documentIds}
onToggle={(id) => onChange({ documentIds: toggle(documentIds, id) })}
options={(documents.data?.documents ?? []).map((doc) => ({
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
}))}
onChange={(ids) => onChange({ documentIds: ids })}
options={documentOptions}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
/>
) : null}
{labsOn ? (
<LinkBlock
<EntityLinkPicker
icon={FlaskConical}
title={t("encounters.form.linkLabResults")}
slot="encounter-link-labs"
pending={labs.isPending}
selected={labResultIds}
onToggle={(id) =>
onChange({ labResultIds: toggle(labResultIds, id) })
}
options={(labs.data?.results ?? []).map((result) => ({
id: result.id,
label: result.panel
? `${result.panel} · ${result.analyte}`
: result.analyte,
date: result.takenAt,
}))}
onChange={(ids) => onChange({ labResultIds: ids })}
options={labOptions}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
/>
) : null}
{illnessOn ? (
<LinkBlock
<EntityLinkPicker
icon={Stethoscope}
title={t("encounters.form.linkConditions")}
slot="encounter-link-conditions"
pending={episodes.isPending}
selected={episodeIds}
onToggle={(id) => onChange({ episodeIds: toggle(episodeIds, id) })}
options={(episodes.data ?? []).map((episode) => ({
id: episode.id,
label: episode.label,
date: episode.onsetAt,
}))}
onChange={(ids) => onChange({ episodeIds: ids })}
options={episodeOptions}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
/>
) : null}
</div>
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { FolderOpen } from "lucide-react";
import { I18nProvider } from "@/lib/i18n/context";
import {
EntityLinkPicker,
filterOptions,
groupOptions,
toggleAll,
type EntityLinkOption,
} from "../entity-link-picker";
const OPTIONS: EntityLinkOption[] = [
{
id: "a",
label: "LDL cholesterol",
meta: "mg/dL",
dateLabel: "12.05.2026",
group: { key: "2026-05-12|Lipids", label: "Lipids · 12.05.2026" },
},
{
id: "b",
label: "HDL cholesterol",
meta: "mg/dL",
dateLabel: "12.05.2026",
group: { key: "2026-05-12|Lipids", label: "Lipids · 12.05.2026" },
},
{
id: "c",
label: "Glucose",
meta: "mg/dL",
dateLabel: "03.02.2026",
group: { key: "2026-02-03|Metabolic", label: "Metabolic · 03.02.2026" },
},
];
describe("filterOptions", () => {
it("returns everything for an empty term", () => {
expect(filterOptions(OPTIONS, "")).toHaveLength(3);
expect(filterOptions(OPTIONS, " ")).toHaveLength(3);
});
it("matches the label, case-insensitively", () => {
const hits = filterOptions(OPTIONS, "glucose");
expect(hits.map((o) => o.id)).toEqual(["c"]);
});
it("matches the group heading, so a panel search finds its members", () => {
const hits = filterOptions(OPTIONS, "lipids");
expect(hits.map((o) => o.id)).toEqual(["a", "b"]);
});
it("matches the date and the meta line", () => {
expect(filterOptions(OPTIONS, "03.02").map((o) => o.id)).toEqual(["c"]);
expect(filterOptions(OPTIONS, "mg/dl")).toHaveLength(3);
});
});
describe("groupOptions", () => {
it("collapses options into ordered groups, first-seen order", () => {
const groups = groupOptions(OPTIONS);
expect(groups.map((g) => g.label)).toEqual([
"Lipids · 12.05.2026",
"Metabolic · 03.02.2026",
]);
expect(groups[0].options.map((o) => o.id)).toEqual(["a", "b"]);
expect(groups[1].options.map((o) => o.id)).toEqual(["c"]);
});
it("returns one flat, headingless group when nothing carries a group", () => {
const flat: EntityLinkOption[] = [
{ id: "x", label: "Knee injury" },
{ id: "y", label: "Migraine" },
];
const groups = groupOptions(flat);
expect(groups).toHaveLength(1);
expect(groups[0].key).toBeNull();
expect(groups[0].label).toBeNull();
expect(groups[0].options).toHaveLength(2);
});
it("returns nothing for an empty option set", () => {
expect(groupOptions([])).toEqual([]);
});
});
describe("toggleAll", () => {
it("adds the missing ids when the group is not fully selected", () => {
expect(toggleAll(["a"], ["a", "b"])).toEqual(["a", "b"]);
});
it("removes the whole group when every id is already selected", () => {
expect(toggleAll(["a", "b", "c"], ["a", "b"])).toEqual(["c"]);
});
it("never duplicates an already-selected id", () => {
expect(toggleAll(["a", "b"], ["b", "c"])).toEqual(["a", "b", "c"]);
});
});
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<EntityLinkPicker> inline summary", () => {
const base = {
icon: FolderOpen,
title: "Lab results",
slot: "test-link",
searchPlaceholder: "Search",
emptyLabel: "Nothing to link",
onChange: () => undefined,
};
it("shows a chip per selected option and the count badge", () => {
const html = render(
<EntityLinkPicker
{...base}
options={OPTIONS}
pending={false}
selected={["a", "c"]}
/>,
);
expect(html).toContain('data-slot="test-link-chips"');
// One chip per selected id, labelled from the option.
expect(html).toContain("LDL cholesterol");
expect(html).toContain("Glucose");
// The add button opens the sheet.
expect(html).toContain('data-slot="test-link-add"');
});
it("renders the empty label when there is nothing to offer", () => {
const html = render(
<EntityLinkPicker {...base} options={[]} pending={false} selected={[]} />,
);
expect(html).toContain("Nothing to link");
expect(html).not.toContain('data-slot="test-link-add"');
});
it("renders a skeleton while the options load", () => {
const html = render(
<EntityLinkPicker {...base} options={[]} pending={true} selected={[]} />,
);
expect(html).toContain('data-slot="skeleton"');
expect(html).not.toContain("Nothing to link");
});
});
+334
View File
@@ -0,0 +1,334 @@
"use client";
/**
* The shared entity link picker: a searchable, grouped, multi-select surface
* for attaching records (documents, lab results, illness episodes, …) to a
* visit or a vaccination.
*
* One pattern, two consumers. The visit form mounts one per family (documents,
* labs, conditions); the vaccination form mounts the documents one. Both used
* to render a flat capped list with no search — 30 lab values arrived as 25
* undifferentiated rows and anything past the cap was unreachable. This block
* replaces that with an inline summary (removable chips + an add button) and a
* sheet that searches, groups and multi-selects.
*
* **The gate blanks the block, it does not post-filter it.** The caller mounts
* this only for a module that is on; a switched-off module leaves nothing here.
*
* Nothing here can block a save: the selection starts empty and stays valid
* empty, and the link cap stays authoritative on the server. This surface only
* ever raises the FETCH limit so grouping has something to group; it never
* raises what may be linked.
*/
import { useMemo, useState } from "react";
import { Check, Plus, Search, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
export interface EntityLinkOption {
id: string;
label: string;
/** Already-localized date string, or null for a target with no date. */
dateLabel?: string | null;
/** Optional secondary muted line (e.g. the analyte under a panel group). */
meta?: string | null;
/**
* Grouping key + already-localized heading. Omit or null for a flat list.
* Options with the same key collapse under one heading, in first-seen order.
*/
group?: { key: string; label: string } | null;
}
/** Lowercased haystack for the client search over one option. */
function haystack(option: EntityLinkOption): string {
return [option.label, option.meta, option.group?.label, option.dateLabel]
.filter(Boolean)
.join(" ")
.toLowerCase();
}
/** Client-side search: the whole term must appear in the option's haystack. */
export function filterOptions(
options: EntityLinkOption[],
term: string,
): EntityLinkOption[] {
const needle = term.trim().toLowerCase();
if (!needle) return options;
return options.filter((option) => haystack(option).includes(needle));
}
export interface EntityLinkGroup {
/** Group key, or null for the flat (ungrouped) bucket. */
key: string | null;
/** Heading to render, or null when the list is flat. */
label: string | null;
options: EntityLinkOption[];
}
/**
* Collapse options into ordered groups. A group's position is where its first
* member appears, so the caller's own ordering (usually newest first) carries
* through. When no option carries a group, the whole list is one flat bucket
* with a null heading.
*/
export function groupOptions(options: EntityLinkOption[]): EntityLinkGroup[] {
const anyGrouped = options.some((option) => option.group != null);
if (!anyGrouped) {
return options.length ? [{ key: null, label: null, options }] : [];
}
const order: string[] = [];
const byKey = new Map<string, EntityLinkGroup>();
for (const option of options) {
const key = option.group?.key ?? "__ungrouped__";
const label = option.group?.label ?? null;
let group = byKey.get(key);
if (!group) {
group = { key, label, options: [] };
byKey.set(key, group);
order.push(key);
}
group.options.push(option);
}
return order.map((key) => byKey.get(key)!);
}
/**
* Toggle a whole group's ids: if every id is already selected, remove them
* all; otherwise add the missing ones. Preserves order and never duplicates.
*/
export function toggleAll(selected: string[], ids: string[]): string[] {
const selectedSet = new Set(selected);
const allOn = ids.every((id) => selectedSet.has(id));
if (allOn) {
const drop = new Set(ids);
return selected.filter((id) => !drop.has(id));
}
const merged = [...selected];
for (const id of ids) if (!selectedSet.has(id)) merged.push(id);
return merged;
}
function toggleOne(selected: string[], id: string): string[] {
return selected.includes(id)
? selected.filter((entry) => entry !== id)
: [...selected, id];
}
export function EntityLinkPicker({
icon: Icon,
title,
slot,
options,
pending,
selected,
onChange,
searchPlaceholder,
emptyLabel,
}: {
icon: LucideIcon;
title: string;
/** data-slot on the inline block; the module-gate tests assert this. */
slot: string;
options: EntityLinkOption[];
pending: boolean;
selected: string[];
onChange: (ids: string[]) => void;
searchPlaceholder: string;
emptyLabel: string;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState("");
const optionById = useMemo(() => {
const map = new Map<string, EntityLinkOption>();
for (const option of options) map.set(option.id, option);
return map;
}, [options]);
const groups = useMemo(
() => groupOptions(filterOptions(options, term)),
[options, term],
);
const selectedSet = new Set(selected);
const selectedChips = selected
.map((id) => optionById.get(id))
.filter((option): option is EntityLinkOption => option != null);
return (
<div className="space-y-2" data-slot={slot}>
<div className="flex items-center gap-2">
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
<span className="text-sm font-medium">{title}</span>
{selected.length > 0 ? (
<Badge variant="outline" className="ml-auto">
{selected.length}
</Badge>
) : null}
</div>
{pending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
) : (
<div className="space-y-2">
{selectedChips.length > 0 ? (
<ul className="flex flex-wrap gap-1.5" data-slot={`${slot}-chips`}>
{selectedChips.map((option) => (
<li key={option.id}>
<button
type="button"
data-slot={`${slot}-chip`}
onClick={() => onChange(toggleOne(selected, option.id))}
className="border-border bg-muted/40 hover:bg-muted focus-visible:ring-ring/50 flex min-h-8 items-center gap-1.5 rounded-full border px-3 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="text-foreground max-w-[12rem] truncate">
{option.label}
</span>
<X
className="text-muted-foreground size-3 shrink-0"
aria-hidden
/>
<span className="sr-only">{t("links.picker.remove")}</span>
</button>
</li>
))}
</ul>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
data-slot={`${slot}-add`}
onClick={() => setOpen(true)}
>
<Plus className="size-4" aria-hidden />
{t("links.picker.add")}
</Button>
</div>
)}
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={title}
contentWidth="lg"
>
<div className="space-y-3">
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="pl-9"
data-slot={`${slot}-search`}
/>
</div>
{groups.length === 0 ? (
<p className="text-muted-foreground px-1 py-6 text-center text-sm">
{t("links.picker.searchNoMatch")}
</p>
) : (
<div className="max-h-[50vh] space-y-4 overflow-y-auto overscroll-contain">
{groups.map((group) => {
const groupIds = group.options.map((option) => option.id);
return (
<div key={group.key ?? "__flat__"} className="space-y-1">
{group.label ? (
<div className="flex items-center justify-between gap-2 px-1">
<span
className="text-muted-foreground text-xs font-medium"
data-slot={`${slot}-group-heading`}
>
{group.label}
</span>
<button
type="button"
data-slot={`${slot}-group-select`}
onClick={() =>
onChange(toggleAll(selected, groupIds))
}
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
>
{t("links.picker.selectAll")}
</button>
</div>
) : null}
<ul className="space-y-1">
{group.options.map((option) => {
const on = selectedSet.has(option.id);
return (
<li key={option.id}>
<button
type="button"
aria-pressed={on}
data-slot={`${slot}-option`}
onClick={() =>
onChange(toggleOne(selected, option.id))
}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
on && "border-primary/40 bg-primary/5",
)}
>
<Check
className={cn(
"size-4 shrink-0",
on ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate text-sm">
{option.label}
</span>
{option.meta ? (
<span className="text-muted-foreground block truncate text-xs">
{option.meta}
</span>
) : null}
</span>
{option.dateLabel ? (
<span className="text-muted-foreground shrink-0 text-xs">
{option.dateLabel}
</span>
) : null}
</button>
</li>
);
})}
</ul>
</div>
);
})}
</div>
)}
<div className="flex justify-end border-t pt-3">
<Button type="button" onClick={() => setOpen(false)}>
{t("links.picker.done")}
</Button>
</div>
</div>
</ResponsiveSheet>
</div>
);
}
@@ -2,7 +2,8 @@
/**
* The from-the-record document link: attach the scanned page a dose was
* transcribed from.
* transcribed from. The shared {@link EntityLinkPicker} — an inline summary
* (removable chips + an add button) over a searchable, month-grouped sheet.
*
* **The gate blanks the block, it does not post-filter it.** When the
* `inboundDocuments` module is off, this renders nothing — no heading, no empty
@@ -15,15 +16,16 @@
import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import {
EntityLinkPicker,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
const OPTION_LIMIT = 25;
const PICKER_FETCH_LIMIT = 200;
interface DocumentListPage {
documents: InboundDocumentDto[];
@@ -47,77 +49,42 @@ export function VaccinationDocumentPicker({
enabled,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
),
});
if (!enabled) return null;
const toggle = (id: string) =>
onChange(
documentIds.includes(id)
? documentIds.filter((entry) => entry !== id)
: [...documentIds, id],
);
const options = (documents.data?.documents ?? []).map((doc) => ({
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
}));
const options: EntityLinkOption[] = (documents.data?.documents ?? []).map(
(doc) => {
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
return {
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
dateLabel: date ? format.date(date) : null,
group: date
? {
key: date.slice(0, 7),
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
}
: null,
};
},
);
return (
<div
className="space-y-2 border-t pt-4"
data-slot="vaccination-document-picker"
>
<div className="flex items-center gap-2">
<FolderOpen className="text-foreground size-4 shrink-0" aria-hidden />
<span className="text-sm font-medium">
{t("vaccinations.form.linkDocuments")}
</span>
{documentIds.length > 0 ? (
<Badge variant="outline" className="ml-auto">
{documentIds.length}
</Badge>
) : null}
</div>
{documents.isPending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t("vaccinations.form.linkNothingToOffer")}
</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
{options.map((option) => {
const on = documentIds.includes(option.id);
return (
<li key={option.id}>
<button
type="button"
aria-pressed={on}
data-slot="vaccination-document-option"
onClick={() => toggle(option.id)}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
on && "border-primary/40 bg-primary/5",
)}
>
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
{option.label}
</span>
{option.date ? (
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(option.date)}
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
<div className="border-t pt-4" data-slot="vaccination-document-picker">
<EntityLinkPicker
icon={FolderOpen}
title={t("vaccinations.form.linkDocuments")}
slot="vaccination-document"
pending={documents.isPending}
selected={documentIds}
onChange={onChange}
options={options}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("vaccinations.form.linkNothingToOffer")}
/>
</div>
);
}