mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user