mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user