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 (
-