mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
31f0d7c9fb
commit
6e05f86e7b
10 files changed
+233
-12
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user