From 1481e38fae4c5b3ad5f7b30d6acd00fb4df1aad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 00:25:04 +0200 Subject: [PATCH] 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. --- messages/de.json | 24 + messages/en.json | 24 + messages/es.json | 24 + messages/fr.json | 24 + messages/it.json | 24 + messages/ko.json | 24 + messages/pl.json | 24 + .../destructive-control-guard.test.ts | 14 + .../documents/document-detail-sheet.tsx | 10 + .../encounters/encounter-related-links.tsx | 66 +++ src/components/encounters/encounter-sheet.tsx | 7 + .../illness/illness-episode-detail.tsx | 12 + .../links/__tests__/related-records.test.tsx | 76 +++ src/components/links/related-records.tsx | 527 ++++++++++++++++++ .../detail/medication-detail-tabs.tsx | 10 + .../detail/medication-related-section.tsx | 61 ++ .../vaccinations/vaccination-sheet.tsx | 12 + src/lib/links/related.ts | 20 +- tests/integration/relation-links.test.ts | 24 + 19 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 src/components/encounters/encounter-related-links.tsx create mode 100644 src/components/links/__tests__/related-records.test.tsx create mode 100644 src/components/links/related-records.tsx create mode 100644 src/components/medications/detail/medication-related-section.tsx diff --git a/messages/de.json b/messages/de.json index 319ae5a90..220f3240b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index a53ab20cd..af02d7890 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index bc30b1b5c..1e5ea3cd1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index e624f5a3b..89798f425 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index eee5718db..64ae2ac4c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index e78bac6cf..a1dc11c14 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 1a9c56897..93796098e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index a6284a586..d1f96bf77 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -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: diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 56052eb88..b11eab8cb 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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. */} + + {/* 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 diff --git a/src/components/encounters/encounter-related-links.tsx b/src/components/encounters/encounter-related-links.tsx new file mode 100644 index 000000000..4114b5a1a --- /dev/null +++ b/src/components/encounters/encounter-related-links.tsx @@ -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 ( +
+ + { + // The other end lists the visit too. + for (const queryKey of [ + queryKeys.encounter(encounterId), + queryKeys.medications(), + queryKeys.vaccinations(), + ]) { + void qc.invalidateQueries({ queryKey }); + } + }} + /> +
+ ); +} diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 654c6ac8f..4c0eefcd0 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -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({ >
+ {isEdit ? ( + + ) : null} {error ? (

{error} diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 91e36a332..bad38802b 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -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 ? : 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 ? ( + + ) : null} {episode ? ( diff --git a/src/components/links/__tests__/related-records.test.tsx b/src/components/links/__tests__/related-records.test.tsx new file mode 100644 index 000000000..5639e5ba9 --- /dev/null +++ b/src/components/links/__tests__/related-records.test.tsx @@ -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( + {node}, + ); +} + +describe("", () => { + it("links every row to the path the server resolved", () => { + const html = render(); + 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(); + expect(html).not.toContain(">SPECIALIST<"); + }); + + it("says how a derived relation runs", () => { + const html = render(); + expect(html).toContain("through the efficacy target"); + }); + + it("leaves out the kinds the surface lists elsewhere, and renders nothing when empty", () => { + const html = render( + , + ); + expect(html).not.toContain("/checkups?visit=e1"); + expect(render()).toBe(""); + expect( + render( + , + ), + ).toBe(""); + }); +}); diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx new file mode 100644 index 000000000..68ce45373 --- /dev/null +++ b/src/components/links/related-records.tsx @@ -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//{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["t"]; +type RelatedKind = RelatedLink["kind"]; + +const KIND_ICONS: Record = { + 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 ( +

    + {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 ( +
  • + + + + + {rowLabel(t, row)} + + + {meta} + + + + +
  • + ); + })} +
+ ); +} + +/** + * 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 ( + + + + + + + ); +} + +/** 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 ( +
+

+ {t("links.related.title")} +

+ +
+ ); +} + +/* ── pickers ──────────────────────────────────────────────────────────── */ + +type LinkableKind = Exclude; + +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({ + 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 ( + + ); +} + +function DocumentPicker({ selected, onChange, anchor, known }: PickerProps) { + const { t } = useTranslations(); + const documents = useVaultDocumentOptions({ enabled: true, anchor }); + return ( + + ); +} + +function VisitPicker({ selected, onChange, anchor, known }: PickerProps) { + const { t } = useTranslations(); + const format = useFormatters(); + const pages = useLinkPickerPages({ + 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 ( + + ); +} + +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("/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 ( + 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 ( + void doses.refetch()} + selected={selected} + onChange={onChange} + options={options} + knownOptions={known} + searchPlaceholder={t("links.picker.searchPlaceholder")} + emptyLabel={t("links.related.nothingToOffer")} + /> + ); +} + +const PICKERS: Record 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 = { + 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(); + 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> + >({}); + + if (inSharedRecord) return null; + + const visible = kinds.filter((kind) => { + const key = KIND_MODULE[kind]; + return ( + key === null || + (user?.modules as Record | 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 ( +
+ {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 ( + row.id) + } + onChange={(ids) => void write(kind, ids)} + anchor={anchor} + known={known} + /> + ); + })} +
+ ); +} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 69f9cb55f..402b26090 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -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({ )} )} + + {/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/ diff --git a/src/components/medications/detail/medication-related-section.tsx b/src/components/medications/detail/medication-related-section.tsx new file mode 100644 index 000000000..a2b487a2d --- /dev/null +++ b/src/components/medications/detail/medication-related-section.tsx @@ -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 ( + +
+ + { + // The other end lists the medication too. + for (const queryKey of [ + queryKeys.medicationDetail(medicationId), + queryKeys.encounters(), + ]) { + void qc.invalidateQueries({ queryKey }); + } + }} + /> +
+
+ ); +} diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 8597b1b84..40a8cce42 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -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 ? ( + + ) : null} {error ? (

{error} diff --git a/src/lib/links/related.ts b/src/lib/links/related.ts index 77701f425..24bd01927 100644 --- a/src/lib/links/related.ts +++ b/src/lib/links/related.ts @@ -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. */ diff --git a/tests/integration/relation-links.test.ts b/tests/integration/relation-links.test.ts index def5458c9..d2fe65994 100644 --- a/tests/integration/relation-links.test.ts +++ b/tests/integration/relation-links.test.ts @@ -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, {