diff --git a/CHANGELOG.md b/CHANGELOG.md index 443200110..b9ee96f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - The AI consent and connector notes in Settings render as normal text now, not faint fine print, so the copy you are meant to read before turning a feature on is legible. - The record-a-visit action on the checkups list now appears only where there is a real practice visit behind the reminder. A reminder you satisfy yourself in the app, like weighing in or a mood check-in, no longer offers to file a visit it never involved. - The practices and doctors address book searches by specialty now, so typing "Zahnmedizin" finds the dentist even when the name and practice hold nothing of the sort. The list groups its entries under their specialty, with the ones you left unspecified gathered at the end, and the visit form's picker shows each contact's field beside the practice. +- Linking documents, lab results and illnesses to a visit is searchable now instead of a flat list you had to scroll. Each link block shows what you have picked as removable chips over an add button that opens a searchable sheet; lab results gather under their panel and sample date so you can attach a whole day's panel in one tap, and documents gather by month. The vaccination form uses the same picker for its document link. - The respiratory-rate tile on the dashboard shows its unit in your own language instead of a fixed English label, and the personalized greeting carries its comma. ### Removed diff --git a/messages/de.json b/messages/de.json index ce64f5c1a..b6ea1dddd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10297,5 +10297,15 @@ "deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.", "deleteFailed": "Eintrag konnte nicht gelöscht werden.", "noSpecialtyGroup": "Ohne Fachrichtung" + }, + "links": { + "picker": { + "add": "Hinzufügen", + "done": "Fertig", + "selectAll": "Alle auswählen", + "searchPlaceholder": "Suchen", + "searchNoMatch": "Kein Treffer", + "remove": "Entfernen" + } } } diff --git a/messages/en.json b/messages/en.json index 8924bd4bb..32cc4f893 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10297,5 +10297,15 @@ "deleteDescription": "Your existing visits stay, and they keep this name.", "deleteFailed": "The entry could not be deleted.", "noSpecialtyGroup": "No specialty" + }, + "links": { + "picker": { + "add": "Add", + "done": "Done", + "selectAll": "Select all", + "searchPlaceholder": "Search", + "searchNoMatch": "No match", + "remove": "Remove" + } } } diff --git a/messages/es.json b/messages/es.json index 58506f46d..209c6eeb5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10297,5 +10297,15 @@ "deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.", "deleteFailed": "No se pudo eliminar la entrada.", "noSpecialtyGroup": "Sin especialidad" + }, + "links": { + "picker": { + "add": "Añadir", + "done": "Listo", + "selectAll": "Seleccionar todo", + "searchPlaceholder": "Buscar", + "searchNoMatch": "Sin coincidencias", + "remove": "Quitar" + } } } diff --git a/messages/fr.json b/messages/fr.json index bafbe30a3..dee4a0860 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10297,5 +10297,15 @@ "deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.", "deleteFailed": "L'entrée n'a pas pu être supprimée.", "noSpecialtyGroup": "Sans spécialité" + }, + "links": { + "picker": { + "add": "Ajouter", + "done": "Terminé", + "selectAll": "Tout sélectionner", + "searchPlaceholder": "Rechercher", + "searchNoMatch": "Aucun résultat", + "remove": "Retirer" + } } } diff --git a/messages/it.json b/messages/it.json index 8b19b3dda..db1fccb71 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10297,5 +10297,15 @@ "deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.", "deleteFailed": "Non è stato possibile eliminare la voce.", "noSpecialtyGroup": "Senza specializzazione" + }, + "links": { + "picker": { + "add": "Aggiungi", + "done": "Fatto", + "selectAll": "Seleziona tutto", + "searchPlaceholder": "Cerca", + "searchNoMatch": "Nessun risultato", + "remove": "Rimuovi" + } } } diff --git a/messages/pl.json b/messages/pl.json index 570364f6c..608997b21 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10297,5 +10297,15 @@ "deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.", "deleteFailed": "Nie udało się usunąć wpisu.", "noSpecialtyGroup": "Bez specjalizacji" + }, + "links": { + "picker": { + "add": "Dodaj", + "done": "Gotowe", + "selectAll": "Zaznacz wszystko", + "searchPlaceholder": "Szukaj", + "searchNoMatch": "Brak wyników", + "remove": "Usuń" + } } } diff --git a/src/components/encounters/encounter-link-pickers.tsx b/src/components/encounters/encounter-link-pickers.tsx index bc7986a36..9f5a45586 100644 --- a/src/components/encounters/encounter-link-pickers.tsx +++ b/src/components/encounters/encounter-link-pickers.tsx @@ -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 ( -
-
- - {title} - {selected.length > 0 ? ( - - {selected.length} - - ) : null} -
- {pending ? ( - - ) : options.length === 0 ? ( -

- {t("encounters.form.linkNothingToOffer")} -

- ) : ( - - )} -
- ); -} - -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( - `/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( - `/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( - `/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 (
{documentsOn ? ( - 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 ? ( - - 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 ? ( - 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}
diff --git a/src/components/links/__tests__/entity-link-picker.test.tsx b/src/components/links/__tests__/entity-link-picker.test.tsx new file mode 100644 index 000000000..b4658f8ce --- /dev/null +++ b/src/components/links/__tests__/entity-link-picker.test.tsx @@ -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( + {node}, + ); +} + +describe(" 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toContain('data-slot="skeleton"'); + expect(html).not.toContain("Nothing to link"); + }); +}); diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx new file mode 100644 index 000000000..ba4494617 --- /dev/null +++ b/src/components/links/entity-link-picker.tsx @@ -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(); + 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(); + 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 ( +
+
+ + {title} + {selected.length > 0 ? ( + + {selected.length} + + ) : null} +
+ + {pending ? ( + + ) : options.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {selectedChips.length > 0 ? ( +
    + {selectedChips.map((option) => ( +
  • + +
  • + ))} +
+ ) : null} + + +
+ )} + + +
+
+ + setTerm(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="pl-9" + data-slot={`${slot}-search`} + /> +
+ + {groups.length === 0 ? ( +

+ {t("links.picker.searchNoMatch")} +

+ ) : ( +
+ {groups.map((group) => { + const groupIds = group.options.map((option) => option.id); + return ( +
+ {group.label ? ( +
+ + {group.label} + + +
+ ) : null} +
    + {group.options.map((option) => { + const on = selectedSet.has(option.id); + return ( +
  • + +
  • + ); + })} +
+
+ ); + })} +
+ )} + +
+ +
+
+
+
+ ); +} diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index 0246172a7..d8efc7dab 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -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( - `/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 ( -
-
- - - {t("vaccinations.form.linkDocuments")} - - {documentIds.length > 0 ? ( - - {documentIds.length} - - ) : null} -
- {documents.isPending ? ( - - ) : options.length === 0 ? ( -

- {t("vaccinations.form.linkNothingToOffer")} -

- ) : ( -
    - {options.map((option) => { - const on = documentIds.includes(option.id); - return ( -
  • - -
  • - ); - })} -
- )} +
+
); }