feat(links): show related records and file the new pairs on the web

A shared related-records component renders related[] as rows that open the
server-resolved path, with how a derived relation runs. The medication
overview lists them and, in the account's own record, files its condition,
documents and visits; the visit sheet files medications and doses. The
condition page, the document sheet and the dose sheet list what their own
blocks do not already show. A dose's visit named by the form's field counts
as the same relation as the link table.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:25:04 +02:00
1 parent b4bc039d16
commit 1481e38fae
19 files changed
+1006 -1

No files matched your search

+24
View File
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Zeitraum: {period}"
}
},
"related": {
"title": "Verwandt",
"kind": {
"labResult": "Laborwert",
"encounter": "Besuch",
"document": "Dokument",
"conditionEpisode": "Erkrankung",
"medication": "Medikament",
"vaccination": "Impfung"
},
"via": {
"encounter": "über einen Besuch",
"efficacyTarget": "über das Wirkziel"
},
"pick": {
"conditionEpisode": "Erkrankungen",
"document": "Dokumente",
"encounter": "Besuche",
"medication": "Medikamente",
"vaccination": "Impfungen"
},
"nothingToOffer": "Noch nichts zum Verknüpfen.",
"saveError": "Die Verknüpfung konnte nicht gespeichert werden."
}
},
"bodySites": {
+24
View File
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Showing {period}"
}
},
"related": {
"title": "Related",
"kind": {
"labResult": "Lab result",
"encounter": "Visit",
"document": "Document",
"conditionEpisode": "Condition",
"medication": "Medication",
"vaccination": "Vaccination"
},
"via": {
"encounter": "through a visit",
"efficacyTarget": "through the efficacy target"
},
"pick": {
"conditionEpisode": "Conditions",
"document": "Documents",
"encounter": "Visits",
"medication": "Medications",
"vaccination": "Vaccinations"
},
"nothingToOffer": "Nothing to link yet.",
"saveError": "The link could not be saved."
}
},
"bodySites": {
+24
View File
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Mostrando {period}"
}
},
"related": {
"title": "Relacionado",
"kind": {
"labResult": "Resultado de laboratorio",
"encounter": "Consulta",
"document": "Documento",
"conditionEpisode": "Enfermedad",
"medication": "Medicamento",
"vaccination": "Vacuna"
},
"via": {
"encounter": "a través de una consulta",
"efficacyTarget": "a través del objetivo de eficacia"
},
"pick": {
"conditionEpisode": "Enfermedades",
"document": "Documentos",
"encounter": "Consultas",
"medication": "Medicamentos",
"vaccination": "Vacunas"
},
"nothingToOffer": "Todavía no hay nada que vincular.",
"saveError": "No se pudo guardar el vínculo."
}
},
"bodySites": {
+24
View File
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Période : {period}"
}
},
"related": {
"title": "Liés",
"kind": {
"labResult": "Résultat d’analyse",
"encounter": "Consultation",
"document": "Document",
"conditionEpisode": "Maladie",
"medication": "Médicament",
"vaccination": "Vaccination"
},
"via": {
"encounter": "via une consultation",
"efficacyTarget": "via la cible d’efficacité"
},
"pick": {
"conditionEpisode": "Maladies",
"document": "Documents",
"encounter": "Consultations",
"medication": "Médicaments",
"vaccination": "Vaccinations"
},
"nothingToOffer": "Rien à lier pour l’instant.",
"saveError": "Le lien n’a pas pu être enregistré."
}
},
"bodySites": {
+24
View File
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Periodo: {period}"
}
},
"related": {
"title": "Correlati",
"kind": {
"labResult": "Valore di laboratorio",
"encounter": "Visita",
"document": "Documento",
"conditionEpisode": "Malattia",
"medication": "Farmaco",
"vaccination": "Vaccinazione"
},
"via": {
"encounter": "tramite una visita",
"efficacyTarget": "tramite l’obiettivo di efficacia"
},
"pick": {
"conditionEpisode": "Malattie",
"document": "Documenti",
"encounter": "Visite",
"medication": "Farmaci",
"vaccination": "Vaccinazioni"
},
"nothingToOffer": "Ancora niente da collegare.",
"saveError": "Non è stato possibile salvare il collegamento."
}
},
"bodySites": {
+24
View File
@@ -12342,6 +12342,30 @@
"period": {
"showing": "{period} 표시 중"
}
},
"related": {
"title": "관련 항목",
"kind": {
"labResult": "검사 결과",
"encounter": "진료",
"document": "문서",
"conditionEpisode": "질환",
"medication": "약",
"vaccination": "예방접종"
},
"via": {
"encounter": "진료를 통해",
"efficacyTarget": "효과 목표를 통해"
},
"pick": {
"conditionEpisode": "질환",
"document": "문서",
"encounter": "진료",
"medication": "약",
"vaccination": "예방접종"
},
"nothingToOffer": "아직 연결할 항목이 없습니다.",
"saveError": "연결을 저장하지 못했습니다."
}
},
"bodySites": {
+24
View File
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Okres: {period}"
}
},
"related": {
"title": "Powiązane",
"kind": {
"labResult": "Wynik badania",
"encounter": "Wizyta",
"document": "Dokument",
"conditionEpisode": "Choroba",
"medication": "Lek",
"vaccination": "Szczepienie"
},
"via": {
"encounter": "przez wizytę",
"efficacyTarget": "przez cel skuteczności"
},
"pick": {
"conditionEpisode": "Choroby",
"document": "Dokumenty",
"encounter": "Wizyty",
"medication": "Leki",
"vaccination": "Szczepienia"
},
"nothingToOffer": "Na razie nie ma czego powiązać.",
"saveError": "Nie udało się zapisać powiązania."
}
},
"bodySites": {
@@ -256,6 +256,20 @@ const REGISTRY: DestructiveEntry[] = [
},
],
},
{
file: "components/links/related-records.tsx",
destroys:
"a relation link between two of the person's own records (a medication and its condition, document or visit; a visit and a medication or dose)",
recovery: "permanent",
confirm: [],
unconfirmed: [
{
control: "remove a chip in a relation link picker",
reason:
"Removing a chip deletes one join row and nothing the person wrote: both records stay untouched, the unlink toast names what was unlinked, and the same picker re-files it with one tap. This is the shape every link picker in the app already has (the visit form, the document sheet, the dose form), and a dialog on one of them alone would make the same act cost two different amounts.",
},
],
},
{
file: "components/insights/coach-panel/coach-conversation.tsx",
destroys:
@@ -86,6 +86,8 @@ import {
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { DocumentRecordLinks } from "./document-record-links";
import { RelatedRecordsBlock } from "@/components/links/related-records";
import type { RelatedLink } from "@/lib/links/related";
import { DocumentFactsSection } from "./document-facts-review";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
@@ -1203,6 +1205,14 @@ export function DocumentDetailSheet({
onChange={(part) => patch.mutate(part)}
/>
{/* v1.43 — the lab results read from this page and the
medications it belongs to. Conditions, visits and doses
have their own blocks above. */}
<RelatedRecordsBlock
related={(doc as { related?: RelatedLink[] }).related}
omitKinds={["conditionEpisode", "encounter", "vaccination"]}
/>
{/* Staged-facts review + the stored-text extract recovery. The
existing extract/confirm chain finally gets its control on
the document itself: pending facts open the review, a read
@@ -0,0 +1,66 @@
"use client";
/**
* The visit sheet's v1.43 relation links: the medications prescribed,
* changed or reviewed at the visit and the doses given there, plus whatever
* else the visit relates to that the form above does not already pick.
*
* Unlike the form's documents, lab results and conditions (a replace-set sent
* with Save), these two write straight through the visit's links route when
* picked, so they need a saved visit and render in edit mode only.
*/
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
RelatedRecordsBlock,
RelationLinkPickers,
} from "@/components/links/related-records";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { RelatedLink } from "@/lib/links/related";
export function EncounterRelatedLinks({
encounterId,
occurredAt,
}: {
encounterId: string;
occurredAt: string | null;
}) {
const qc = useQueryClient();
const detail = useQuery({
queryKey: queryKeys.encounter(encounterId),
queryFn: () =>
apiGet<{ related?: RelatedLink[] }>(
`/api/encounters/${encodeURIComponent(encounterId)}`,
),
});
// The pickers seed from this read; before it lands they would offer an
// empty selection that the first tap could not compute a change from.
if (!detail.data) return null;
const related = detail.data.related;
return (
<div className="space-y-4 border-t pt-4" data-slot="encounter-related">
<RelatedRecordsBlock
related={related}
omitKinds={["document", "labResult", "conditionEpisode"]}
/>
<RelationLinkPickers
key={encounterId}
linksPath={`/api/encounters/${encodeURIComponent(encounterId)}/links`}
kinds={["medication", "vaccination"]}
related={related}
anchor={occurredAt}
onSaved={() => {
// The other end lists the visit too.
for (const queryKey of [
queryKeys.encounter(encounterId),
queryKeys.medications(),
queryKeys.vaccinations(),
]) {
void qc.invalidateQueries({ queryKey });
}
}}
/>
</div>
);
}
@@ -60,6 +60,7 @@ import {
nowLocalValue,
type EncounterDraft,
} from "./encounter-form";
import { EncounterRelatedLinks } from "./encounter-related-links";
export function EncounterSheet({
open,
@@ -163,6 +164,12 @@ export function EncounterSheet({
>
<div className="space-y-4">
<EncounterForm draft={draft} onChange={setDraft} isEdit={isEdit} />
{isEdit ? (
<EncounterRelatedLinks
encounterId={encounter.id}
occurredAt={encounter.occurredAt}
/>
) : null}
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
@@ -14,6 +14,8 @@ import Link from "next/link";
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { RelatedRecordsCard } from "@/components/links/related-records";
import type { RelatedLink } from "@/lib/links/related";
import { EpisodeSymptomsCard } from "@/components/symptoms/episode-symptoms-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -204,6 +206,16 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
so the reverse match would be wrong often enough to train the person
to ignore every suggestion in the product. */}
{episode ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
{/* v1.43 — what else the condition relates to: the medications that
treat it and the lab results it shares a visit with. Documents and
visits have their own cards above. */}
{episode ? (
<RelatedRecordsCard
related={(episode as { related?: RelatedLink[] }).related}
omitKinds={["document", "encounter"]}
/>
) : null}
</RevealWhenSettled>
{episode ? (
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { RelatedLink } from "@/lib/links/related";
import { RelatedRecordsBlock, RelatedRecordsList } from "../related-records";
const RELATED: RelatedLink[] = [
{
kind: "conditionEpisode",
id: "c1",
label: "Hypothyroidism",
date: "2026-01-20T00:00:00.000Z",
href: "/illness/c1",
via: "direct",
},
{
kind: "encounter",
id: "e1",
label: "SPECIALIST",
date: "2026-07-01T09:00:00.000Z",
href: "/checkups?visit=e1",
via: "direct",
},
{
kind: "labResult",
id: "l1",
label: "TSH",
date: "2026-06-30T09:00:00.000Z",
href: "/labs/b1",
via: "efficacyTarget",
},
];
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<RelatedRecordsList>", () => {
it("links every row to the path the server resolved", () => {
const html = render(<RelatedRecordsList related={RELATED} />);
expect(html).toContain('href="/illness/c1"');
expect(html).toContain('href="/checkups?visit=e1"');
expect(html).toContain('href="/labs/b1"');
expect(html).toContain("Hypothyroidism");
});
it("names a visit labelled by its kind from the reader's bundle", () => {
const html = render(<RelatedRecordsList related={RELATED} />);
expect(html).not.toContain(">SPECIALIST<");
});
it("says how a derived relation runs", () => {
const html = render(<RelatedRecordsList related={RELATED} />);
expect(html).toContain("through the efficacy target");
});
it("leaves out the kinds the surface lists elsewhere, and renders nothing when empty", () => {
const html = render(
<RelatedRecordsList related={RELATED} omitKinds={["encounter"]} />,
);
expect(html).not.toContain("/checkups?visit=e1");
expect(render(<RelatedRecordsList related={[]} />)).toBe("");
expect(
render(
<RelatedRecordsBlock
related={RELATED}
omitKinds={["conditionEpisode", "encounter", "labResult"]}
/>,
),
).toBe("");
});
});
+527
View File
@@ -0,0 +1,527 @@
"use client";
/**
* "Verwandt": the records related to the one on screen, and the pickers that
* file the v1.43 relation links.
*
* Every row comes from the detail read's `related[]`, resolved on the server:
* the label, the date, the web path and how the relation runs (a stored link,
* a visit both are filed against, or a medication's efficacy target). The
* client renders it and never re-derives it, so the web, iOS and a push link
* all open the same place.
*
* The pickers write through the record's own links route
* (`POST/DELETE /api/<record>/{id}/links`) one change at a time and refresh
* the detail read afterwards. Those routes are own-account only, so the
* pickers render only in the caller's own record. A module that is off leaves
* no picker behind, the same rule the visit form follows.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
CalendarDays,
ChevronRight,
FlaskConical,
FolderOpen,
Link2,
Pill,
Syringe,
type LucideIcon,
} from "lucide-react";
import {
EntityLinkPicker,
serverPagingFrom,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import {
pickerPivot,
useLinkPickerPages,
} from "@/components/links/use-link-picker-pages";
import { fetchEpisodePage } from "@/components/encounters/encounter-link-pickers";
import {
ENCOUNTER_KINDS,
encounterKindText,
} from "@/components/encounters/encounter-labels";
import {
fetchEncounterPage,
recordOptions,
} from "@/components/documents/document-record-links";
import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
import { TileHeader } from "@/components/insights/tile-header";
import { Card, CardContent } from "@/components/ui/card";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO } from "@/lib/encounters/dto";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { RelatedLink } from "@/lib/links/related";
import type { EncounterKind } from "@/generated/prisma/client";
type Translate = ReturnType<typeof useTranslations>["t"];
type RelatedKind = RelatedLink["kind"];
const KIND_ICONS: Record<RelatedKind, LucideIcon> = {
labResult: FlaskConical,
encounter: CalendarDays,
document: FolderOpen,
conditionEpisode: Activity,
medication: Pill,
vaccination: Syringe,
};
/** Literal calls, so the i18n coverage guard sees every key. */
function kindText(t: Translate, kind: RelatedKind): string {
switch (kind) {
case "labResult":
return t("links.related.kind.labResult");
case "encounter":
return t("links.related.kind.encounter");
case "document":
return t("links.related.kind.document");
case "conditionEpisode":
return t("links.related.kind.conditionEpisode");
case "medication":
return t("links.related.kind.medication");
case "vaccination":
return t("links.related.kind.vaccination");
}
}
function viaText(t: Translate, via: RelatedLink["via"]): string | null {
if (via === "encounter") return t("links.related.via.encounter");
if (via === "efficacyTarget") return t("links.related.via.efficacyTarget");
return null;
}
/**
* A visit without a reason or a practitioner is labelled by its kind, sent as
* the enum constant so each reader names it from their own bundle.
*/
function rowLabel(t: Translate, row: RelatedLink): string {
if (
row.kind === "encounter" &&
(ENCOUNTER_KINDS as string[]).includes(row.label)
) {
return encounterKindText(t, row.label as EncounterKind);
}
return row.label;
}
/** The related rows, grouped in the server's order. Nothing when empty. */
export function RelatedRecordsList({
related,
omitKinds = [],
}: {
related: readonly RelatedLink[] | undefined;
/** Kinds this surface already lists in a block of its own. */
omitKinds?: readonly RelatedKind[];
}) {
const { t } = useTranslations();
const format = useFormatters();
const rows = (related ?? []).filter((row) => !omitKinds.includes(row.kind));
if (rows.length === 0) return null;
return (
<ul className="divide-y" data-slot="related-records">
{rows.map((row) => {
const Icon = KIND_ICONS[row.kind];
const via = viaText(t, row.via);
const meta = [
kindText(t, row.kind),
row.date ? format.date(row.date) : null,
via,
]
.filter(Boolean)
.join(", ");
return (
<li key={`${row.kind}:${row.id}`}>
<Link
href={row.href}
data-slot="related-record"
className="hover:bg-muted/50 focus-visible:ring-ring/50 -mx-2 flex min-h-11 items-center gap-3 rounded-md px-2 py-2 focus-visible:ring-[3px] focus-visible:outline-none"
>
<Icon
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate text-sm">
{rowLabel(t, row)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{meta}
</span>
</span>
<ChevronRight
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
</Link>
</li>
);
})}
</ul>
);
}
/**
* The list in its own tile, for a page made of cards. Nothing when the
* record relates to nothing the surface does not already show.
*/
export function RelatedRecordsCard({
related,
omitKinds = [],
}: {
related: readonly RelatedLink[] | undefined;
omitKinds?: readonly RelatedKind[];
}) {
const { t } = useTranslations();
if (!(related ?? []).some((row) => !omitKinds.includes(row.kind))) {
return null;
}
return (
<Card data-slot="related-records-card">
<CardContent className="space-y-4">
<TileHeader icon={Link2} title={t("links.related.title")} />
<RelatedRecordsList related={related} omitKinds={omitKinds} />
</CardContent>
</Card>
);
}
/** The list under a field-style label, for a sheet. */
export function RelatedRecordsBlock({
related,
omitKinds = [],
}: {
related: readonly RelatedLink[] | undefined;
omitKinds?: readonly RelatedKind[];
}) {
const { t } = useTranslations();
if (!(related ?? []).some((row) => !omitKinds.includes(row.kind))) {
return null;
}
return (
<div className="space-y-1.5" data-slot="related-records-block">
<p className="text-sm leading-none font-medium">
{t("links.related.title")}
</p>
<RelatedRecordsList related={related} omitKinds={omitKinds} />
</div>
);
}
/* ── pickers ──────────────────────────────────────────────────────────── */
type LinkableKind = Exclude<RelatedKind, "labResult">;
interface PickerProps {
selected: string[];
onChange: (ids: string[]) => void;
anchor: string | null;
known: EntityLinkOption[];
}
function ConditionPicker({ selected, onChange, anchor, known }: PickerProps) {
const { t } = useTranslations();
const format = useFormatters();
const pages = useLinkPickerPages<IllnessEpisodeDTO>({
enabled: true,
pivot: pickerPivot(anchor),
queryKey: queryKeys.illnessEpisodesPicker,
fetchPage: fetchEpisodePage,
});
const options: EntityLinkOption[] = pages.items.map((episode) => ({
id: episode.id,
label: episode.label,
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
href: `/illness/${encodeURIComponent(episode.id)}`,
}));
return (
<EntityLinkPicker
icon={Activity}
title={t("links.related.pick.conditionEpisode")}
slot="related-link-conditionEpisode"
pending={pages.pending}
error={pages.error}
errorLabel={t("links.picker.loadError")}
onRetry={pages.retry}
selected={selected}
onChange={onChange}
options={options}
server={serverPagingFrom(pages)}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
function DocumentPicker({ selected, onChange, anchor, known }: PickerProps) {
const { t } = useTranslations();
const documents = useVaultDocumentOptions({ enabled: true, anchor });
return (
<EntityLinkPicker
icon={FolderOpen}
title={t("links.related.pick.document")}
slot="related-link-document"
pending={documents.pending}
error={documents.error}
errorLabel={t("links.picker.loadError")}
onRetry={documents.retry}
selected={selected}
onChange={onChange}
options={documents.options}
server={documents.server}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
function VisitPicker({ selected, onChange, anchor, known }: PickerProps) {
const { t } = useTranslations();
const format = useFormatters();
const pages = useLinkPickerPages<EncounterDTO>({
enabled: true,
pivot: pickerPivot(anchor),
queryKey: queryKeys.encounterPicker,
fetchPage: fetchEncounterPage,
serverSearch: false,
});
const options = recordOptions(
[...pages.items]
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
.map((visit) => ({
id: visit.id,
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
href: `/checkups?visit=${encodeURIComponent(visit.id)}`,
})),
anchor,
{
suggested: t("links.picker.suggested"),
date: (iso: string) => format.date(iso),
},
);
return (
<EntityLinkPicker
icon={CalendarDays}
title={t("links.related.pick.encounter")}
slot="related-link-encounter"
pending={pages.pending}
error={pages.error}
errorLabel={t("links.picker.loadError")}
onRetry={pages.retry}
selected={selected}
onChange={onChange}
options={options}
server={serverPagingFrom(pages)}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
interface MedicationListRow {
id: string;
name: string;
dose: string;
active: boolean;
}
function MedicationPicker({ selected, onChange, known }: PickerProps) {
const { t } = useTranslations();
const list = useQuery({
queryKey: queryKeys.medications(),
queryFn: () => apiGet<MedicationListRow[]>("/api/medications"),
});
const options: EntityLinkOption[] = (
Array.isArray(list.data) ? list.data : []
).map((med) => ({
id: med.id,
label: med.dose ? `${med.name} ${med.dose}` : med.name,
href: `/medications/${encodeURIComponent(med.id)}`,
}));
return (
<EntityLinkPicker
icon={Pill}
title={t("links.related.pick.medication")}
slot="related-link-medication"
pending={list.isPending}
error={list.isError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void list.refetch()}
selected={selected}
onChange={onChange}
options={options}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
const { t } = useTranslations();
const format = useFormatters();
const doses = useVaccinations(null, true);
const options = recordOptions(
(doses.data?.vaccinations ?? []).map((dose) => ({
id: dose.id,
label: dose.catalogEntry?.slug
? t(`vaccinations.catalog.${dose.catalogEntry.slug}`)
: (dose.vaccineName ?? t("vaccinations.suggestion.unnamed")),
occurredAt: dose.occurredAt,
href: `/vaccinations?dose=${encodeURIComponent(dose.id)}`,
})),
anchor,
{
suggested: t("links.picker.suggested"),
date: (iso: string) => format.date(iso),
},
);
return (
<EntityLinkPicker
icon={Syringe}
title={t("links.related.pick.vaccination")}
slot="related-link-vaccination"
pending={doses.isPending}
error={doses.isError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void doses.refetch()}
selected={selected}
onChange={onChange}
options={options}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("links.related.nothingToOffer")}
/>
);
}
const PICKERS: Record<LinkableKind, (props: PickerProps) => React.ReactNode> = {
conditionEpisode: ConditionPicker,
document: DocumentPicker,
encounter: VisitPicker,
medication: MedicationPicker,
vaccination: DosePicker,
};
/** The module each pickable kind belongs to; the visit is core. */
const KIND_MODULE: Record<LinkableKind, string | null> = {
conditionEpisode: "illness",
document: "inboundDocuments",
encounter: null,
medication: "medications",
vaccination: "vaccinations",
};
/**
* One picker per kind the record may file, each writing through `linksPath`.
*
* The caller mounts it keyed on the record so the local selection starts from
* the server's answer whenever the record changes; a failed write puts the
* selection back and says so.
*/
export function RelationLinkPickers({
linksPath,
kinds,
related,
anchor,
onSaved,
}: {
/** `/api/medications/{id}/links` and the like. */
linksPath: string;
kinds: readonly LinkableKind[];
related: readonly RelatedLink[] | undefined;
/** The record's own date, where the pickers open. */
anchor: string | null;
/** Refresh the detail read after a write. */
onSaved: () => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const { user } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const direct = useMemo(() => {
const out = new Map<LinkableKind, RelatedLink[]>();
for (const row of related ?? []) {
if (row.via !== "direct" || row.kind === "labResult") continue;
out.set(row.kind, [...(out.get(row.kind) ?? []), row]);
}
return out;
}, [related]);
const [selection, setSelection] = useState<
Partial<Record<LinkableKind, string[]>>
>({});
if (inSharedRecord) return null;
const visible = kinds.filter((kind) => {
const key = KIND_MODULE[kind];
return (
key === null ||
(user?.modules as Record<string, boolean> | undefined)?.[key] !== false
);
});
if (visible.length === 0) return null;
const write = async (kind: LinkableKind, next: string[]) => {
const before =
selection[kind] ?? (direct.get(kind) ?? []).map((row) => row.id);
setSelection((current) => ({ ...current, [kind]: next }));
const added = next.filter((id) => !before.includes(id));
const removed = before.filter((id) => !next.includes(id));
try {
if (added.length > 0) {
await apiPost(linksPath, { targetKind: kind, targetIds: added });
}
if (removed.length > 0) {
await apiDelete(linksPath, { targetKind: kind, targetIds: removed });
}
onSaved();
} catch (cause) {
setSelection((current) => ({ ...current, [kind]: before }));
showErrorToast(t("links.related.saveError"), { cause });
}
};
return (
<div className="space-y-4" data-slot="relation-link-pickers">
{visible.map((kind) => {
const Picker = PICKERS[kind];
const known: EntityLinkOption[] = (direct.get(kind) ?? []).map(
(row) => ({
id: row.id,
label: rowLabel(t, row),
dateLabel: row.date ? format.date(row.date) : null,
href: row.href,
}),
);
return (
<Picker
key={kind}
selected={
selection[kind] ?? (direct.get(kind) ?? []).map((row) => row.id)
}
onChange={(ids) => void write(kind, ids)}
anchor={anchor}
known={known}
/>
);
})}
</div>
);
}
@@ -95,6 +95,8 @@ import type { MedicationPayload } from "@/components/medications/wizard/wizard-p
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
import { AutoMarkTakenBody } from "@/components/medications/sections/auto-mark-taken-section";
import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section";
import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section";
import type { RelatedLink } from "@/lib/links/related";
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
// both chart bodies load through `next/dynamic` (client-only) with the
@@ -184,6 +186,8 @@ export interface MedicationDetailSnapshot {
canStartCourse?: boolean;
recordedSchedules?: ScheduleSnapshot[];
schedules: ScheduleSnapshot[];
/** v1.43 — the records this medication relates to (server-resolved). */
related?: RelatedLink[];
}
// Slugs are ASCII / locale-independent so the URL stays stable across
@@ -660,6 +664,12 @@ export function MedicationDetailTabs({
)}
</MedicationDetailSection>
)}
<MedicationRelatedSection
medicationId={medication.id}
startsOn={medication.startsOn ?? null}
related={medication.related}
/>
</TabsContent>
{/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/
@@ -0,0 +1,61 @@
"use client";
/**
* The medication's "Verwandt" section on the overview tab: what the detail
* read's `related[]` lists (its indication, its documents, the visits it was
* prescribed or reviewed at, and the readings of the marker it targets), and
* in the caller's own record the pickers that file the first three.
*/
import { useQueryClient } from "@tanstack/react-query";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import {
RelatedRecordsList,
RelationLinkPickers,
} from "@/components/links/related-records";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { RelatedLink } from "@/lib/links/related";
export function MedicationRelatedSection({
medicationId,
startsOn,
related,
}: {
medicationId: string;
startsOn: string | null;
related: RelatedLink[] | undefined;
}) {
const { t } = useTranslations();
const qc = useQueryClient();
const { inSharedRecord } = useRecordCapabilities();
if ((related ?? []).length === 0 && inSharedRecord) return null;
return (
<MedicationDetailSection
titleId="medication-uebersicht-related-heading"
title={t("links.related.title")}
dataSlot="medication-uebersicht-related"
>
<div className="space-y-4">
<RelatedRecordsList related={related} />
<RelationLinkPickers
key={medicationId}
linksPath={`/api/medications/${encodeURIComponent(medicationId)}/links`}
kinds={["conditionEpisode", "document", "encounter"]}
related={related}
anchor={startsOn}
onSaved={() => {
// The other end lists the medication too.
for (const queryKey of [
queryKeys.medicationDetail(medicationId),
queryKeys.encounters(),
]) {
void qc.invalidateQueries({ queryKey });
}
}}
/>
</div>
</MedicationDetailSection>
);
}
@@ -37,6 +37,8 @@ import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useTranslations } from "@/lib/i18n/context";
import { RelatedRecordsBlock } from "@/components/links/related-records";
import type { RelatedLink } from "@/lib/links/related";
import {
useVaccination,
useVaccinationMutations,
@@ -149,6 +151,16 @@ export function VaccinationSheet({
: undefined
}
/>
{/* v1.43 — the visit the dose was given at and anything else it
relates to; its pages have their own picker above. */}
{vaccination ? (
<RelatedRecordsBlock
related={
(detail.data as { related?: RelatedLink[] } | undefined)?.related
}
omitKinds={["document"]}
/>
) : null}
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
+19 -1
View File
@@ -300,7 +300,25 @@ async function stored(
sourceId,
targetKind: to as LinkTargetKind,
});
return rows.map((row) => ({ kind: to, id: row.id, via: "direct" }));
const ids = rows.map((row) => row.id);
// A dose also names its visit by scalar (`VaccinationRecord.encounterId`,
// the form's visit field). Both say the same thing, so both are read and
// the visit is listed once.
if (from === "vaccination" && to === "encounter") {
const dose = await tx.vaccinationRecord.findFirst({
where: { id: sourceId, userId },
select: { encounterId: true },
});
if (dose?.encounterId) ids.push(dose.encounterId);
} else if (from === "encounter" && to === "vaccination") {
const doses = await tx.vaccinationRecord.findMany({
where: { encounterId: sourceId, userId, deletedAt: null },
select: { id: true },
orderBy: { occurredAt: "asc" },
});
ids.push(...doses.map((dose) => dose.id));
}
return [...new Set(ids)].map((id) => ({ kind: to, id, via: "direct" }));
}
/** The things filed against the visits this record is filed against. */
+24
View File
@@ -318,6 +318,30 @@ describe("related[]", () => {
]);
});
it("lists a dose's visit named by the form's scalar field, once", async () => {
const prisma = getPrismaClient();
await prisma.vaccinationRecord.update({
where: { id: owner.vaccination.id },
data: { encounterId: owner.encounter.id },
});
await linkTargets(prisma, {
userId: OWNER,
sourceKind: "vaccination",
sourceId: owner.vaccination.id,
targetKind: "encounter",
targetIds: [owner.encounter.id],
});
const fromDose = await related("vaccination", owner.vaccination.id);
expect(fromDose.filter((r) => r.kind === "encounter")).toHaveLength(1);
const fromVisit = await related("encounter", owner.encounter.id);
expect(fromVisit.filter((r) => r.kind === "vaccination")).toEqual([
expect.objectContaining({
id: owner.vaccination.id,
href: `/vaccinations?dose=${owner.vaccination.id}`,
}),
]);
});
it("never lists another account's records, nor a deleted one", async () => {
const prisma = getPrismaClient();
await linkTargets(prisma, {