From 6e05f86e7b8954386cdcfaeadaf25281c2fb6c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 07:10:56 +0200 Subject: [PATCH] fix(vaccinations): show a dose's linked documents and keep them on edit A dose opened from the list carried no document links, since only the detail read has them. The edit sheet showed an empty picker, and saving any change sent that empty set, which the route reads as unlink everything. The sheet now reads the dose's own links before it shows them, and a save leaves the links alone unless they were changed. A link to one dose or visit (/vaccinations?dose=, /checkups?visit=) now scrolls to it and opens it for someone who may edit it. --- src/app/checkups/page-client.tsx | 6 +- src/app/vaccinations/page.tsx | 10 ++- src/components/encounters/visits-section.tsx | 10 +++ .../__tests__/vaccination-form.test.ts | 62 +++++++++++++++++++ .../vaccinations/use-vaccinations.ts | 13 ++++ .../vaccination-document-picker.tsx | 22 ++++++- .../vaccinations/vaccination-form.tsx | 36 +++++++++-- .../vaccinations/vaccination-sheet.tsx | 26 +++++++- .../vaccinations/vaccinations-view.tsx | 10 +++ src/hooks/use-deep-linked-record.ts | 50 +++++++++++++++ 10 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 src/hooks/use-deep-linked-record.ts diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index 7f6a08714..e36a95c13 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { useSearchParams } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; @@ -38,7 +39,10 @@ export default function CheckupsPageClient() { const mounted = useMounted(); const queryClient = useQueryClient(); const { t } = useTranslations(); - const [view, setView] = useState("vorsorge"); + // A link to one visit (`?visit=`, from a document's link chip) lands on + // the visits tab, where the section opens it. + const linkedVisit = useSearchParams()?.get("visit"); + const [view, setView] = useState(linkedVisit ? "visits" : "vorsorge"); const refresh = useCallback( () => diff --git a/src/app/vaccinations/page.tsx b/src/app/vaccinations/page.tsx index 13687ce7b..89ae8dc11 100644 --- a/src/app/vaccinations/page.tsx +++ b/src/app/vaccinations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; @@ -42,5 +42,11 @@ export default function VaccinationsPage() { return ; } - return ; + // The view reads `?dose=` through `useSearchParams`, which needs a + // boundary on a statically rendered page. + return ( + }> + + + ); } diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 3b2ad8c9a..eef6bd78e 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -22,6 +22,7 @@ import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SectionHeading } from "@/components/ui/section-heading"; import { Skeleton } from "@/components/ui/skeleton"; +import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useEncounters, type Encounter } from "@/hooks/use-encounters"; import { useTranslations } from "@/lib/i18n/context"; @@ -49,6 +50,15 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { const upcoming = list.data?.upcoming ?? []; const past = list.data?.past ?? []; + + // `?visit=` — a document's link chip names the visit it is filed + // against. Scroll to it, and open it for someone who may edit it. + useDeepLinkedRecord({ + param: "visit", + records: list.data ? [...upcoming, ...past] : undefined, + anchorAttribute: "data-encounter-id", + open: canManageVisits ? openSheet : undefined, + }); const empty = upcoming.length === 0 && past.length === 0; const addButton = canAddVisit ? ( diff --git a/src/components/vaccinations/__tests__/vaccination-form.test.ts b/src/components/vaccinations/__tests__/vaccination-form.test.ts index 0d53865b5..fac6d1426 100644 --- a/src/components/vaccinations/__tests__/vaccination-form.test.ts +++ b/src/components/vaccinations/__tests__/vaccination-form.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; +import type { Vaccination } from "../use-vaccinations"; import { + draftFromVaccination, draftHasIdentity, draftInstant, draftToBody, @@ -72,3 +74,63 @@ describe("vaccination form draft", () => { }); }); }); + +/** + * #1024 — a dose opened from the LIST carries no `documents` (the list omits + * them; only the detail read has them). The edit draft used to seed that + * absence as an empty link set and send it on Save, and the route reads a + * present array as "replace the links with this": editing any field of a dose + * silently unlinked every document filed against it. + */ +describe("vaccination form document links", () => { + const listRow = { + id: "dose-1", + occurredAt: "2025-09-12T00:00:00.000Z", + antigenSlug: "covid19", + vaccineName: null, + doseNumber: null, + seriesDoses: null, + lotNumber: null, + site: null, + catalogEntry: null, + series: [], + practitioner: null, + encounter: null, + reminderId: null, + note: null, + createdAt: "2025-09-12T00:00:00.000Z", + updatedAt: "2025-09-12T00:00:00.000Z", + } as Vaccination; + + it("an edit seeded from a list row does not claim the dose has no documents", () => { + const draft = draftFromVaccination(listRow); + expect(draft.documentIds).toBeNull(); + }); + + it("saving such an edit leaves the links alone", () => { + const body = draftToBody({ + ...draftFromVaccination(listRow), + lotNumber: "AB123", + }); + expect(body).not.toBeNull(); + expect(body).not.toHaveProperty("documentIds"); + }); + + it("a detail row seeds its linked documents", () => { + const draft = draftFromVaccination({ + ...listRow, + documents: [ + { id: "doc-1", label: "Record", date: null, redacted: false }, + ], + }); + expect(draft.documentIds).toEqual(["doc-1"]); + }); + + it("a deliberate unlink of every document is still sent", () => { + const body = draftToBody({ + ...draftFromVaccination(listRow), + documentIds: [], + }); + expect(body!.documentIds).toEqual([]); + }); +}); diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 8cdd874d0..2834e093e 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -70,6 +70,19 @@ export function useVaccinations(antigenSlug?: string | null, enabled = true) { }); } +/** + * One dose with its links. The list omits each dose's documents, so an edit + * reads them here before it shows or sends them. + */ +export function useVaccination(id: string | null) { + return useQuery({ + queryKey: queryKeys.vaccination(id ?? ""), + enabled: id !== null, + queryFn: () => + apiGet(`${BASE}/${encodeURIComponent(id ?? "")}`), + }); +} + /** * Create / edit / soft-delete / restore a dose. * diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index baf8f2da5..d1778773b 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -17,6 +17,11 @@ * * Nothing here can block a save: the list starts empty and stays valid empty, * and linking is optional, capped and idempotent behind the link facade. + * + * On an edit the selection is what the dose is filed against on the server, + * read from the dose's detail route; the block waits for that read rather + * than showing an empty set, which would both hide the links and offer to + * save their removal. */ import { FolderOpen } from "lucide-react"; @@ -28,6 +33,9 @@ export function VaccinationDocumentPicker({ enabled, anchor, documentIds, + seedPending = false, + seedError = false, + onRetrySeed, onChange, }: { /** The `inboundDocuments` module flag — false blanks the block entirely. */ @@ -35,6 +43,11 @@ export function VaccinationDocumentPicker({ /** The dose's own date (ISO), for the suggestions on top. */ anchor: string | null; documentIds: string[]; + /** The dose's own links are still loading. */ + seedPending?: boolean; + /** Reading the dose's own links failed. */ + seedError?: boolean; + onRetrySeed?: () => void; onChange: (documentIds: string[]) => void; }) { const { t } = useTranslations(); @@ -48,10 +61,13 @@ export function VaccinationDocumentPicker({ icon={FolderOpen} title={t("vaccinations.form.linkDocuments")} slot="vaccination-document" - pending={vault.pending} - error={vault.error} + pending={vault.pending || seedPending} + error={vault.error || seedError} errorLabel={t("links.picker.loadError")} - onRetry={vault.retry} + onRetry={() => { + if (vault.error) vault.retry(); + if (seedError) onRetrySeed?.(); + }} selected={documentIds} onChange={onChange} options={vault.options} diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index 67d192faa..a000a3f39 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -59,7 +59,14 @@ export interface VaccinationDraft { practitioner: Practitioner | null; encounterId: string | null; note: string; - documentIds: string[]; + /** + * The documents the person has chosen, or `null` for "untouched: whatever + * the dose is filed against now". A dose opened from the list arrives + * without its links (only the detail read carries them), and an empty array + * here is sent as "unlink everything" — which is how editing a lot number + * used to strip every page filed against the dose. + */ + documentIds: string[] | null; } /** Today as a local `YYYY-MM-DD`, the DateField default and max. */ @@ -102,7 +109,7 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft { practitioner: row.practitioner, encounterId: row.encounter?.id ?? null, note: row.note ?? "", - documentIds: row.documents?.map((doc) => doc.id) ?? [], + documentIds: row.documents ? row.documents.map((doc) => doc.id) : null, }; } @@ -143,16 +150,30 @@ export function draftToBody( practitionerId: draft.practitioner?.id ?? null, encounterId: draft.encounterId, note: draft.note.trim() || null, - documentIds: draft.documentIds, + // Absent unless the person changed the links: the route reads a present + // array as the whole new set. + ...(draft.documentIds !== null ? { documentIds: draft.documentIds } : {}), }; } export function VaccinationForm({ draft, onChange, + savedDocuments, }: { draft: VaccinationDraft; onChange: (next: VaccinationDraft) => void; + /** + * What the dose is filed against on the server, for an edit: the ids, or + * `pending` while the detail read runs, or `error` when it failed. A create + * passes nothing and starts from an empty set. + */ + savedDocuments?: { + ids: string[] | null; + pending: boolean; + error: boolean; + retry: () => void; + }; }) { const { t } = useTranslations(); const { user } = useAuth(); @@ -306,7 +327,14 @@ export function VaccinationForm({ patch({ documentIds })} /> diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index d8c83951f..3f76cc21b 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -37,7 +37,11 @@ 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 { useVaccinationMutations, type Vaccination } from "./use-vaccinations"; +import { + useVaccination, + useVaccinationMutations, + type Vaccination, +} from "./use-vaccinations"; import { VaccinationForm, draftFromVaccination, @@ -63,6 +67,9 @@ export function VaccinationSheet({ }) { const { t } = useTranslations(); const { create, update, remove } = useVaccinationMutations(); + // The list row carries no document links; the detail read does. Until it + // answers, the draft's links stay "untouched" and Save leaves them alone. + const detail = useVaccination(vaccination?.id ?? null); const [confirmingDelete, setConfirmingDelete] = useState(false); // Seeded once at mount; the caller remounts on every open by varying `key`. const [draft, setDraft] = useState(() => @@ -125,7 +132,22 @@ export function VaccinationSheet({ } >
- + document.id) ?? + null, + pending: detail.isPending, + error: detail.isError, + retry: () => void detail.refetch(), + } + : undefined + } + /> {error ? (

{error} diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index 59acbd2c7..fd44e310d 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -19,6 +19,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; +import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; @@ -65,6 +66,15 @@ export function VaccinationsView() { setSheetOpen(true); }; + // `?dose=` — a document's link chip names the dose it is filed + // against. Scroll to it, and open it for someone who may edit it. + useDeepLinkedRecord({ + param: "dose", + records: data?.vaccinations, + anchorAttribute: "data-vaccination-id", + open: canManageProfile ? openEdit : undefined, + }); + const addButton = canAddDose ? (