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