mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
+10
-1
@@ -8544,7 +8544,9 @@
|
||||
"site": "Impfstelle",
|
||||
"siteNone": "Keine Angabe",
|
||||
"practitioner": "Praxis / Arzt",
|
||||
"note": "Notiz"
|
||||
"note": "Notiz",
|
||||
"linkDocuments": "Dokument verknüpfen",
|
||||
"linkNothingToOffer": "Noch keine Dokumente vorhanden."
|
||||
},
|
||||
"site": {
|
||||
"LEFT_ARM": "Linker Oberarm",
|
||||
@@ -8596,6 +8598,13 @@
|
||||
"everyMonthsFew": "alle {count} Monate",
|
||||
"everyMonthsOther": "alle {count} Monate"
|
||||
},
|
||||
"suggestion": {
|
||||
"single": "Zu dieser Impfung hinzufügen?",
|
||||
"choose": "Zu welcher Impfung gehört der Scan?",
|
||||
"linked": "Mit Impfung verknüpft",
|
||||
"failed": "Verknüpfung fehlgeschlagen.",
|
||||
"unnamed": "Impfung"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Noch keine Impfung erfasst",
|
||||
"description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen."
|
||||
|
||||
+10
-1
@@ -8544,7 +8544,9 @@
|
||||
"site": "Injection site",
|
||||
"siteNone": "Not specified",
|
||||
"practitioner": "Practice / doctor",
|
||||
"note": "Note"
|
||||
"note": "Note",
|
||||
"linkDocuments": "Link a document",
|
||||
"linkNothingToOffer": "No documents yet."
|
||||
},
|
||||
"site": {
|
||||
"LEFT_ARM": "Left upper arm",
|
||||
@@ -8596,6 +8598,13 @@
|
||||
"everyMonthsFew": "every {count} months",
|
||||
"everyMonthsOther": "every {count} months"
|
||||
},
|
||||
"suggestion": {
|
||||
"single": "File against this vaccination?",
|
||||
"choose": "Which vaccination does this scan belong to?",
|
||||
"linked": "Linked to vaccination",
|
||||
"failed": "Linking failed.",
|
||||
"unnamed": "Vaccination"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No vaccination logged yet",
|
||||
"description": "Transcribe your vaccination card line by line — a date and the vaccine are enough."
|
||||
|
||||
+10
-1
@@ -8544,7 +8544,9 @@
|
||||
"site": "Lugar de aplicación",
|
||||
"siteNone": "Sin especificar",
|
||||
"practitioner": "Consulta / médico",
|
||||
"note": "Nota"
|
||||
"note": "Nota",
|
||||
"linkDocuments": "Vincular un documento",
|
||||
"linkNothingToOffer": "Aún no hay documentos."
|
||||
},
|
||||
"site": {
|
||||
"LEFT_ARM": "Brazo izquierdo",
|
||||
@@ -8596,6 +8598,13 @@
|
||||
"everyMonthsFew": "cada {count} meses",
|
||||
"everyMonthsOther": "cada {count} meses"
|
||||
},
|
||||
"suggestion": {
|
||||
"single": "¿Asociar a esta vacuna?",
|
||||
"choose": "¿A qué vacuna pertenece este escaneo?",
|
||||
"linked": "Vinculado a la vacuna",
|
||||
"failed": "No se pudo vincular.",
|
||||
"unnamed": "Vacuna"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aún no hay ninguna vacuna registrada",
|
||||
"description": "Transcribe tu cartilla de vacunación línea por línea: bastan una fecha y la vacuna."
|
||||
|
||||
+10
-1
@@ -8544,7 +8544,9 @@
|
||||
"site": "Site d'injection",
|
||||
"siteNone": "Non précisé",
|
||||
"practitioner": "Cabinet / médecin",
|
||||
"note": "Note"
|
||||
"note": "Note",
|
||||
"linkDocuments": "Associer un document",
|
||||
"linkNothingToOffer": "Aucun document pour l'instant."
|
||||
},
|
||||
"site": {
|
||||
"LEFT_ARM": "Bras gauche",
|
||||
@@ -8596,6 +8598,13 @@
|
||||
"everyMonthsFew": "tous les {count} mois",
|
||||
"everyMonthsOther": "tous les {count} mois"
|
||||
},
|
||||
"suggestion": {
|
||||
"single": "Associer à cette vaccination ?",
|
||||
"choose": "À quelle vaccination appartient ce scan ?",
|
||||
"linked": "Associé à la vaccination",
|
||||
"failed": "L'association a échoué.",
|
||||
"unnamed": "Vaccination"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucune vaccination enregistrée",
|
||||
"description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent."
|
||||
|
||||
+10
-1
@@ -8544,7 +8544,9 @@
|
||||
"site": "Sede di iniezione",
|
||||
"siteNone": "Non specificato",
|
||||
"practitioner": "Studio / medico",
|
||||
"note": "Nota"
|
||||
"note": "Nota",
|
||||
"linkDocuments": "Collega un documento",
|
||||
"linkNothingToOffer": "Nessun documento ancora."
|
||||
},
|
||||
"site": {
|
||||
"LEFT_ARM": "Braccio sinistro",
|
||||
@@ -8596,6 +8598,13 @@
|
||||
"everyMonthsFew": "ogni {count} mesi",
|
||||
"everyMonthsOther": "ogni {count} mesi"
|
||||
},
|
||||
"suggestion": {
|
||||
"single": "Collegare a questa vaccinazione?",
|
||||
"choose": "A quale vaccinazione appartiene questa scansione?",
|
||||
"linked": "Collegato alla vaccinazione",
|
||||
"failed": "Collegamento non riuscito.",
|
||||
"unnamed": "Vaccinazione"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Nessuna vaccinazione registrata",
|
||||
"description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino."
|
||||
|
||||
+10
-1
@@ -8544,7 +8544,9 @@
|
||||
"site": "Miejsce podania",
|
||||
"siteNone": "Nie podano",
|
||||
"practitioner": "Gabinet / lekarz",
|
||||
"note": "Notatka"
|
||||
"note": "Notatka",
|
||||
"linkDocuments": "Powiąż dokument",
|
||||
"linkNothingToOffer": "Brak dokumentów."
|
||||
},
|
||||
"site": {
|
||||
"LEFT_ARM": "Lewe ramię",
|
||||
@@ -8596,6 +8598,13 @@
|
||||
"everyMonthsFew": "co {count} miesiące",
|
||||
"everyMonthsOther": "co {count} miesięcy"
|
||||
},
|
||||
"suggestion": {
|
||||
"single": "Powiązać z tym szczepieniem?",
|
||||
"choose": "Do którego szczepienia należy ten skan?",
|
||||
"linked": "Powiązano ze szczepieniem",
|
||||
"failed": "Powiązanie nie powiodło się.",
|
||||
"unnamed": "Szczepienie"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Nie zarejestrowano jeszcze żadnego szczepienia",
|
||||
"description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka."
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* `GET /api/vaccinations/suggest?anchor=<iso>` — which dose does a document
|
||||
* dated `anchor` most plausibly belong to?
|
||||
*
|
||||
* The document upload review asks this when a scan is classified `VACCINATION`.
|
||||
* The rule — the ±7-day window (shared with the visit moment), and the
|
||||
* one-pre-selects / two-offer-a-picker verdict — lives in
|
||||
* `src/lib/vaccinations/document-suggestion.ts` and is answered here so the
|
||||
* browser never re-derives it.
|
||||
*
|
||||
* A read of the record's own doses, so it declares the same `profile` domain
|
||||
* the immunization list does. `userId` is narrowed from auth; the anchor is the
|
||||
* only input and it is a date, not an id.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { vaccinationSuggestQuerySchema } from "@/lib/validations/vaccinations";
|
||||
import { suggestVaccinationForDate } from "@/lib/vaccinations/document-suggestion";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = vaccinationSuggestQuerySchema.safeParse({
|
||||
anchor: params.get("anchor") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "vaccination.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await suggestVaccinationForDate(prisma, {
|
||||
userId: user.id,
|
||||
anchor: new Date(parsed.data.anchor),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "vaccination.dose.suggest", entity_type: "vaccination" },
|
||||
meta: {
|
||||
verdict: result.kind,
|
||||
candidates:
|
||||
result.kind === "many"
|
||||
? result.vaccinations.length
|
||||
: result.kind === "one"
|
||||
? 1
|
||||
: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -42,10 +42,14 @@ export const vaccinationKeys = {
|
||||
vaccinationList: (antigenSlug?: string | null) =>
|
||||
["vaccinations", "list", antigenSlug ?? null] as const,
|
||||
vaccination: (id: string) => ["vaccinations", "detail", id] as const,
|
||||
/** The upload suggestion for one anchor date; the anchor is part of the key. */
|
||||
vaccinationSuggestion: (anchor: string) =>
|
||||
["vaccinations", "suggest", anchor] as const,
|
||||
/** Mutation keys — the factory is the only legal source of these too. */
|
||||
vaccinationCreate: () => ["vaccinations", "create"] as const,
|
||||
vaccinationUpdate: () => ["vaccinations", "update"] as const,
|
||||
vaccinationDelete: () => ["vaccinations", "delete"] as const,
|
||||
vaccinationRestore: () => ["vaccinations", "restore"] as const,
|
||||
vaccinationBooster: () => ["vaccinations", "booster"] as const,
|
||||
vaccinationLink: () => ["vaccinations", "link"] as const,
|
||||
} as const;
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* "Gehört dieser Scan zu einer Impfung?" — the same rule the visit suggestion
|
||||
* uses, applied to the dose direction.
|
||||
*
|
||||
* A document classified `VACCINATION` arriving in the vault offers to file
|
||||
* itself against a dose recorded around the same date. The rule is NOT
|
||||
* re-invented here: the window is the encounter suggestion's exported constant
|
||||
* (`ENCOUNTER_SUGGEST_WINDOW_DAYS`), so the two moments cannot drift to
|
||||
* different windows, and the verdict shape is the same discriminated union:
|
||||
*
|
||||
* exactly one → pre-select it, visibly, with one undo
|
||||
* two or more → offer a picker with NOTHING pre-selected
|
||||
* zero → offer nothing at all
|
||||
*
|
||||
* The middle branch is the whole point. Pre-selecting the first of two is the
|
||||
* failure that teaches a person to stop reading suggestions; this module
|
||||
* returns the SHAPE of the answer, and a caller cannot collapse "many" into
|
||||
* "one" without writing the collapse itself. Breaking that is the watched-red
|
||||
* this moment owes (WR-8).
|
||||
*
|
||||
* It never blocks an upload: every caller treats the verdict as an offer, and
|
||||
* the filing works ignored, dismissed, or never rendered.
|
||||
*
|
||||
* When the shared filing-moment module from the visits milestone lands on
|
||||
* `main`, this moment registers with it and this file collapses into that
|
||||
* registration; until then it holds the same spec locally, and the window
|
||||
* import is what keeps the two honest.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Above one already means "offer a picker", so the exact count past this cap changes nothing. */
|
||||
const MAX_CANDIDATES = 20;
|
||||
|
||||
/** One candidate dose, resolved enough to render without a second round trip. */
|
||||
export interface VaccinationSuggestion {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
/** The catalogue slug when the dose carries one, for the client to name it. */
|
||||
antigenSlug: string | null;
|
||||
/** The person's own wording, when that is all the dose has. */
|
||||
vaccineName: string | null;
|
||||
}
|
||||
|
||||
export type VaccinationSuggestionResult =
|
||||
| { kind: "one"; vaccination: VaccinationSuggestion }
|
||||
| { kind: "many"; vaccinations: VaccinationSuggestion[] }
|
||||
| { kind: "none" };
|
||||
|
||||
/** The window around an anchor, inclusive on both edges — same width as the visit moment. */
|
||||
export function vaccinationSuggestWindow(anchor: Date): {
|
||||
from: Date;
|
||||
to: Date;
|
||||
} {
|
||||
const half = ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
|
||||
return {
|
||||
from: new Date(anchor.getTime() - half),
|
||||
to: new Date(anchor.getTime() + half),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the dose a document dated `anchor` most plausibly belongs to.
|
||||
*
|
||||
* `userId` is the caller's resolved record id, fed straight to the `where`;
|
||||
* this module never accepts one from a request body.
|
||||
*/
|
||||
export async function suggestVaccinationForDate(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { userId: string; anchor: Date },
|
||||
): Promise<VaccinationSuggestionResult> {
|
||||
if (Number.isNaN(input.anchor.getTime())) return { kind: "none" };
|
||||
|
||||
const { from, to } = vaccinationSuggestWindow(input.anchor);
|
||||
|
||||
const rows = await tx.vaccinationRecord.findMany({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
deletedAt: null,
|
||||
occurredAt: { gte: from, lte: to },
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
take: MAX_CANDIDATES,
|
||||
select: {
|
||||
id: true,
|
||||
occurredAt: true,
|
||||
antigenSlug: true,
|
||||
vaccineName: true,
|
||||
},
|
||||
});
|
||||
|
||||
const vaccinations: VaccinationSuggestion[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
antigenSlug: row.antigenSlug,
|
||||
vaccineName: row.vaccineName,
|
||||
}));
|
||||
|
||||
if (vaccinations.length === 0) return { kind: "none" };
|
||||
if (vaccinations.length === 1) {
|
||||
return { kind: "one", vaccination: vaccinations[0]! };
|
||||
}
|
||||
return { kind: "many", vaccinations };
|
||||
}
|
||||
@@ -231,3 +231,20 @@ export const vaccinationBoosterSchema = z
|
||||
.strict();
|
||||
|
||||
export type VaccinationBoosterInput = z.infer<typeof vaccinationBoosterSchema>;
|
||||
|
||||
/* ── upload suggestion ────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The single input to the upload suggestion: the document's anchor date. No
|
||||
* ids — the candidate doses come from the caller's own record, narrowed from
|
||||
* the session.
|
||||
*/
|
||||
export const vaccinationSuggestQuerySchema = z
|
||||
.object({
|
||||
anchor: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type VaccinationSuggestQuery = z.infer<
|
||||
typeof vaccinationSuggestQuerySchema
|
||||
>;
|
||||
Reference in new issue
Block a user