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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 07:10:56 +02:00
1 parent 31f0d7c9fb
commit 6e05f86e7b
10 files changed
+233 -12

No files matched your search

+5 -1
View File
@@ -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=<id>`, 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(
() =>
+8 -2
View File
@@ -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 <PageAuthGate />;
}
return <VaccinationsView />;
// The view reads `?dose=` through `useSearchParams`, which needs a
// boundary on a statically rendered page.
return (
<Suspense fallback={<PageAuthGate />}>
<VaccinationsView />
</Suspense>
);
}
@@ -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=<id>` — 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 ? (
@@ -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([]);
});
});
@@ -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<Vaccination>(`${BASE}/${encodeURIComponent(id ?? "")}`),
});
}
/**
* Create / edit / soft-delete / restore a dose.
*
@@ -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}
@@ -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({
<VaccinationDocumentPicker
enabled={user?.modules?.inboundDocuments === true}
anchor={anchor}
documentIds={draft.documentIds}
documentIds={draft.documentIds ?? savedDocuments?.ids ?? []}
seedPending={
draft.documentIds === null && (savedDocuments?.pending ?? false)
}
seedError={
draft.documentIds === null && (savedDocuments?.error ?? false)
}
onRetrySeed={savedDocuments?.retry}
onChange={(documentIds) => patch({ documentIds })}
/>
</div>
@@ -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<VaccinationDraft>(() =>
@@ -125,7 +132,22 @@ export function VaccinationSheet({
}
>
<div className="space-y-4">
<VaccinationForm draft={draft} onChange={setDraft} />
<VaccinationForm
draft={draft}
onChange={setDraft}
savedDocuments={
vaccination
? {
ids:
detail.data?.documents?.map((document) => document.id) ??
null,
pending: detail.isPending,
error: detail.isError,
retry: () => void detail.refetch(),
}
: undefined
}
/>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
@@ -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=<id>` — 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 ? (
<Button
className="min-h-11 sm:min-h-9"
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
/**
* Open the record a link points at: `/vaccinations?dose=<id>`,
* `/checkups?visit=<id>`.
*
* The link chips on a document name the record they point at, and a chip that
* lands on a list with nothing selected leaves the person searching the list
* for the one row they just tapped. Once the list has loaded, the row named by
* `param` is scrolled into view and handed to `open` (the page's own edit
* sheet, or nothing for a reader who may not edit). Each id is acted on once,
* so closing the sheet does not reopen it on the next render.
*
* An id the list does not hold (deleted since, or outside what this grant
* shows) is ignored: the page simply renders as it would without the link.
*/
export function useDeepLinkedRecord<T extends { id: string }>({
param,
records,
anchorAttribute,
open,
}: {
/** The query parameter carrying the id. */
param: string;
/** The loaded list, or undefined while it loads. */
records: readonly T[] | undefined;
/** The data attribute each row carries its id in, for the scroll. */
anchorAttribute: string;
/** Called once with the linked record; omit to only scroll. */
open?: (record: T) => void;
}): void {
// Null outside the app router (a component rendered on its own, as the
// unit tests do): no link, nothing to open.
const wanted = useSearchParams()?.get(param) ?? null;
const handled = useRef<string | null>(null);
useEffect(() => {
if (!wanted || !records || handled.current === wanted) return;
handled.current = wanted;
const record = records.find((entry) => entry.id === wanted);
if (!record) return;
document
.querySelector(`[${anchorAttribute}="${CSS.escape(wanted)}"]`)
?.scrollIntoView({ block: "center" });
open?.(record);
}, [wanted, records, anchorAttribute, open]);
}