mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
b4bc039d16
commit
1481e38fae
19 files changed
+1006
-1
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in new issue
Block a user