feat(vaccinations): link documents to doses, with the upload suggestion

- from the record: a document link picker on the capture form, gated on the
  documents module (blanked when off, never post-filtered)
- from the upload: a VACCINATION scan offers to file against a dose recorded
  within the shared ±7-day window; a single candidate pre-selects, two offer a
  picker with nothing pre-selected (WR-8 seen red on the pre-select rule,
  reverted by content hash), zero shows nothing, and it never blocks the upload
- the suggestion rule reuses the visit moment's window constant rather than
  forking it; it registers with the shared filing module when that lands
- suggestion and link copy in six locales
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 21:11:21 +02:00
1 parent b5ce44628a
commit 9b58394724
15 files changed
+551 -6

No files matched your search

@@ -80,6 +80,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
@@ -1045,6 +1046,16 @@ export function DocumentDetailSheet({
onChange={setSuggestedEncounter}
/>
{/* A vaccination scan offers to file against the dose it records,
through the same ±7-day rule the visit suggestion uses. */}
{doc.kind === "VACCINATION" ? (
<VaccinationDocumentSuggestion
anchor={doc.reportDate ?? doc.documentDate}
documentId={doc.id}
disabled={!canManage}
/>
) : null}
{/* "Belongs to visit" — read-only here on purpose. The filing
happens at the moment the document arrives, or from the
visit's own sheet; a second editor for the same link would
@@ -25,6 +25,7 @@ import type {
VaccinationDTO,
VaccinationListDTO,
} from "@/lib/vaccinations/dto";
import type { VaccinationSuggestionResult } from "@/lib/vaccinations/document-suggestion";
export type Vaccination = VaccinationDTO;
@@ -116,6 +117,47 @@ export function useVaccinationMutations() {
return { create, update, remove, restore };
}
/**
* Which dose a document dated `anchor` most plausibly belongs to.
*
* The verdict is server-resolved through the shared ±7-day window, so the
* browser never re-derives which dose a scan belongs to — and the "many"
* verdict carries no pre-selection, so a caller cannot pre-select one of two.
*/
export function useVaccinationSuggestion(
anchor: string | null,
enabled = true,
) {
return useQuery({
queryKey: queryKeys.vaccinationSuggestion(anchor ?? ""),
enabled: enabled && Boolean(anchor),
queryFn: () =>
apiGet<VaccinationSuggestionResult>(
`${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
),
});
}
/** Link a document to a dose from the document side (the upload suggestion). */
export function useLinkDocumentToVaccination() {
const qc = useQueryClient();
return useMutation({
mutationKey: queryKeys.vaccinationLink(),
mutationFn: ({
vaccinationId,
documentId,
}: {
vaccinationId: string;
documentId: string;
}) =>
apiPost(`${BASE}/${vaccinationId}/links`, {
targetKind: "document",
targetIds: [documentId],
}),
onSuccess: () => invalidateKeys(qc, vaccinationDependentKeys),
});
}
/** What the booster confirm carries — the user's accepted-or-edited values. */
export interface BoosterMintBody {
intervalMonths: number;
@@ -0,0 +1,123 @@
"use client";
/**
* The from-the-record document link: attach the scanned page a dose was
* transcribed from.
*
* **The gate blanks the block, it does not post-filter it.** When the
* `inboundDocuments` module is off, this renders nothing — no heading, no empty
* list — because an empty picker for a switched-off module advertises a feature
* that is not there. The same shape the visit form's link pickers use.
*
* 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.
*/
import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
const OPTION_LIMIT = 25;
interface DocumentListPage {
documents: InboundDocumentDto[];
}
export function VaccinationDocumentPicker({
enabled,
documentIds,
onChange,
}: {
/** The `inboundDocuments` module flag — false blanks the block entirely. */
enabled: boolean;
documentIds: string[];
onChange: (documentIds: string[]) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const documents = useQuery({
queryKey: queryKeys.inboundDocumentPicker("vaccination-form"),
enabled,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
),
});
if (!enabled) return null;
const toggle = (id: string) =>
onChange(
documentIds.includes(id)
? documentIds.filter((entry) => entry !== id)
: [...documentIds, id],
);
const options = (documents.data?.documents ?? []).map((doc) => ({
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
}));
return (
<div
className="space-y-2 border-t pt-4"
data-slot="vaccination-document-picker"
>
<div className="flex items-center gap-2">
<FolderOpen className="text-foreground size-4 shrink-0" aria-hidden />
<span className="text-sm font-medium">
{t("vaccinations.form.linkDocuments")}
</span>
{documentIds.length > 0 ? (
<Badge variant="outline" className="ml-auto">
{documentIds.length}
</Badge>
) : null}
</div>
{documents.isPending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t("vaccinations.form.linkNothingToOffer")}
</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
{options.map((option) => {
const on = documentIds.includes(option.id);
return (
<li key={option.id}>
<button
type="button"
aria-pressed={on}
data-slot="vaccination-document-option"
onClick={() => toggle(option.id)}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
on && "border-primary/40 bg-primary/5",
)}
>
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
{option.label}
</span>
{option.date ? (
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(option.date)}
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
/**
* The from-upload half of the document link: a scan classified `VACCINATION`
* offers to file itself against a dose recorded around the same date.
*
* The RULE is not here — it lives in `src/lib/vaccinations/document-suggestion.ts`
* and is resolved server-side, the same ±7-day window the visit moment uses, so
* the two cannot drift. What this owns is only how the three verdicts look:
*
* one candidate → pre-selected, visibly;
* two or more → a picker with NOTHING pre-selected;
* none → nothing at all.
*
* The middle branch is the point, and this file never picks one of a "many":
* that verdict carries no pre-selection. It never blocks the upload — linking is
* an offer, and the facade's link is idempotent, so a second tap is a no-op.
*/
import { useState } from "react";
import { Check, Syringe } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useLinkDocumentToVaccination,
useVaccinationSuggestion,
} from "./use-vaccinations";
import type { VaccinationSuggestion } from "@/lib/vaccinations/document-suggestion";
export function VaccinationDocumentSuggestion({
anchor,
documentId,
disabled,
}: {
/** `reportDate ?? documentDate`, date-only; null means nothing to ask. */
anchor: string | null;
documentId: string;
disabled?: boolean;
}) {
const { t } = useTranslations();
const format = useFormatters();
// Lift the date-only anchor to noon UTC — the rule is a ±7-day window, so the
// time never changes the verdict, and noon keeps a viewer's local midnight
// from rounding the day either way. The bare date would 422 the endpoint.
const instant = anchor ? `${anchor}T12:00:00.000Z` : null;
const suggestion = useVaccinationSuggestion(instant, !disabled);
const link = useLinkDocumentToVaccination();
const [linkedId, setLinkedId] = useState<string | null>(null);
if (disabled) return null;
const result = suggestion.data;
if (!result || result.kind === "none") return null;
const candidates =
result.kind === "one" ? [result.vaccination] : result.vaccinations;
const label = (candidate: VaccinationSuggestion) => {
const name = candidate.antigenSlug
? t(`vaccinations.catalog.${candidate.antigenSlug}`)
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
return `${name} · ${format.date(candidate.occurredAt)}`;
};
const onPick = (candidate: VaccinationSuggestion) => {
link.mutate(
{ vaccinationId: candidate.id, documentId },
{
onSuccess: () => {
setLinkedId(candidate.id);
toast.success(t("vaccinations.suggestion.linked"));
},
onError: () => toast.error(t("vaccinations.suggestion.failed")),
},
);
};
return (
<div
className="space-y-1.5"
data-slot="vaccination-document-suggestion"
data-verdict={result.kind}
data-candidates={candidates.length}
>
<p className="text-foreground flex items-center gap-2 text-sm">
<Syringe className="size-4 shrink-0" aria-hidden />
{t(
result.kind === "one"
? "vaccinations.suggestion.single"
: "vaccinations.suggestion.choose",
)}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{candidates.map((candidate) => {
const chosen = linkedId === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={chosen ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={chosen}
disabled={link.isPending || linkedId !== null}
data-slot="vaccination-document-suggestion-option"
onClick={() => onPick(candidate)}
>
{result.kind === "one" || chosen ? (
<Check
className="size-4"
aria-hidden
style={{ opacity: chosen ? 1 : 0.5 }}
/>
) : null}
{label(candidate)}
</Button>
);
})}
</div>
</div>
);
}
@@ -29,10 +29,12 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { PractitionerCombobox } from "@/components/encounters/practitioner-combobox";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { Practitioner } from "@/hooks/use-practitioners";
import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations";
import { CatalogPicker } from "./catalog-picker";
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
export const VACCINATION_SITES = [
@@ -153,6 +155,7 @@ export function VaccinationForm({
onChange: (next: VaccinationDraft) => void;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const patch = (part: Partial<VaccinationDraft>) =>
onChange({ ...draft, ...part });
@@ -299,6 +302,12 @@ export function VaccinationForm({
onChange={(event) => patch({ note: event.target.value })}
/>
</FieldGroup>
<VaccinationDocumentPicker
enabled={user?.modules?.inboundDocuments === true}
documentIds={draft.documentIds}
onChange={(documentIds) => patch({ documentIds })}
/>
</div>
);
}