Merge tickets part 2 into release/v1.42.0

# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 13:07:02 +02:00
commit 9d4a50282e
80 files changed
+3975 -818

No files matched your search

+1 -1
View File
@@ -151,7 +151,7 @@ const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{
},
{
labelKey: "previewVaccinations",
keys: ["vaccinations", "vaccinationLinks"],
keys: ["vaccinations", "vaccinationLinks", "customVaccines"],
},
{
labelKey: "previewReminders",
@@ -0,0 +1,128 @@
/**
* v1.42 (#1005) — the person's own vaccines on the client: the booster offer
* a dose logged against one raises, the name it carries in the list, and the
* body the definition sheet sends.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { VaccinationDTO } from "@/lib/vaccinations/dto";
import { boosterOfferFor } from "../booster-mint-prompt";
import { customVaccineBody } from "../custom-vaccine-sheet";
import { draftHasIdentity, draftToBody, emptyDraft } from "../vaccination-form";
import { VaccinationList } from "../vaccination-list";
const OWN = {
id: "cv-1",
name: "Travel combo",
components: ["typhoid", "hepatitis-a"],
typicalSeriesDoses: 1,
boosterIntervalMonths: 36,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
function dose(over: Partial<VaccinationDTO>): VaccinationDTO {
return {
id: "dose-1",
occurredAt: "2026-02-01T00:00:00.000Z",
antigenSlug: null,
vaccineName: null,
doseNumber: null,
seriesDoses: null,
lotNumber: null,
site: null,
catalogEntry: null,
customVaccineId: OWN.id,
customVaccine: OWN,
series: [
{ antigen: "typhoid", position: 1, total: 1, booster: false },
{ antigen: "hepatitis-a", position: 1, total: 1, booster: false },
],
practitioner: null,
encounter: null,
reminderId: null,
note: null,
createdAt: "2026-02-01T00:00:00.000Z",
updatedAt: "2026-02-01T00:00:00.000Z",
...over,
};
}
describe("the booster offer", () => {
it("is raised for an own vaccine with an interval, under its own name", () => {
expect(boosterOfferFor(dose({}))).toEqual({
intervalMonths: 36,
slug: null,
name: "Travel combo",
});
});
it("is not raised for an own vaccine without an interval, or a removed one", () => {
expect(
boosterOfferFor(
dose({ customVaccine: { ...OWN, boosterIntervalMonths: null } }),
),
).toBeNull();
expect(boosterOfferFor(dose({ customVaccine: null }))).toBeNull();
});
});
describe("the list", () => {
it("files the dose under each antigen, marked with the own vaccine's name", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<VaccinationList records={[dose({})]} />
</I18nProvider>,
);
expect(html).toContain('data-antigen="typhoid"');
expect(html).toContain('data-antigen="hepatitis-a"');
expect(html.match(/· Travel combo/g)).toHaveLength(2);
});
});
describe("the forms", () => {
it("accepts an own vaccine as the dose's only identity and sends it", () => {
const draft = emptyDraft({ customVaccineId: "cv-1" });
expect(draftHasIdentity(draft)).toBe(true);
expect(draftToBody(draft)).toMatchObject({
customVaccineId: "cv-1",
antigenSlug: null,
vaccineName: null,
});
});
it("sends a definition only with a name and a disease, antigens in catalogue order", () => {
expect(
customVaccineBody({
name: " ",
components: ["tetanus"],
typicalSeriesDoses: "",
boosterIntervalMonths: "",
}),
).toBeNull();
expect(
customVaccineBody({
name: "X",
components: [],
typicalSeriesDoses: "",
boosterIntervalMonths: "",
}),
).toBeNull();
expect(
customVaccineBody({
name: " Travel combo ",
components: ["typhoid", "tetanus"],
typicalSeriesDoses: "2",
boosterIntervalMonths: "",
}),
).toEqual({
name: "Travel combo",
components: ["tetanus", "typhoid"],
typicalSeriesDoses: 2,
boosterIntervalMonths: null,
});
});
});
@@ -93,6 +93,8 @@ describe("vaccination form document links", () => {
lotNumber: null,
site: null,
catalogEntry: null,
customVaccineId: null,
customVaccine: null,
series: [],
practitioner: null,
encounter: null,
@@ -26,6 +26,8 @@ function record(
lotNumber: null,
site: null,
catalogEntry: null,
customVaccineId: null,
customVaccine: null,
practitioner: null,
encounter: null,
reminderId: null,
@@ -34,22 +34,40 @@ import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
import {
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { useBoosterMint, type Vaccination } from "./use-vaccinations";
/**
* Whether a freshly created dose should raise the offer at all. Only a
* catalogue entry that carries a booster interval does; a free-text row and a
* Whether a freshly created dose should raise the offer at all. Only an entry
* that carries a booster interval and an antigen to key it on does — a
* catalogue pick or the person's own definition alike; a free-text row and a
* one-off vaccine never prompt.
*/
export function boosterOfferFor(
record: Vaccination,
): { intervalMonths: number; catalogName: string } | null {
const entry = resolveCatalogEntry(record.antigenSlug);
if (!entry || entry.boosterIntervalMonths === null) return null;
export function boosterOfferFor(record: Vaccination): {
intervalMonths: number;
/** The catalogue slug, or null for the person's own definition. */
slug: string | null;
/** The definition's own name, or null for a catalogue entry. */
name: string | null;
} | null {
const entry = resolveVaccineEntry(
record,
customLookupOf([record.customVaccine]),
);
if (
!entry ||
entry.boosterIntervalMonths === null ||
entry.components.length === 0
) {
return null;
}
return {
intervalMonths: entry.boosterIntervalMonths,
catalogName: entry.slug,
slug: entry.slug,
name: entry.name,
};
}
@@ -71,7 +89,7 @@ export function BoosterMintPrompt({
// the person's own locale — the server never resolves an i18n key.
const defaultLabel =
record && offer
? `${t(`vaccinations.catalog.${record.antigenSlug}`)} ${t("vaccinations.booster.labelSuffix")}`
? `${offer.slug ? t(`vaccinations.catalog.${offer.slug}`) : offer.name} ${t("vaccinations.booster.labelSuffix")}`
: "";
const [label, setLabel] = useState(defaultLabel);
const [months, setMonths] = useState<number>(offer?.intervalMonths ?? 12);
+39 -10
View File
@@ -14,24 +14,40 @@
*
* The per-entry `source` renders verbatim as the footnote — a citation is a
* proper noun, like a unit, and is not translated.
*
* The person's own vaccine definitions (v1.42, #1005) render through the same
* templates, with the antigens they listed first and no footnote: their
* numbers are the person's, not a cited schedule's.
*/
import { useTranslations } from "@/lib/i18n/context";
import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog";
import {
resolveVaccineEntry,
type ResolvedVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
type Translate = ReturnType<typeof useTranslations>["t"];
type TranslateCount = ReturnType<typeof useTranslations>["tCount"];
/** The impersonal sentences that apply to this entry, in reading order. */
function infoSentences(
slug: string | null,
entry: ResolvedVaccineEntry | null,
t: Translate,
tCount: TranslateCount,
): { lines: string[]; source: string } | null {
const entry = resolveCatalogEntry(slug);
): { lines: string[]; source: string | null } | null {
if (!entry) return null;
const lines: string[] = [];
if (entry.kind === "custom" && entry.components.length > 0) {
lines.push(
t("vaccinations.custom.protectsAgainst", {
list: entry.components
.map((antigen) => t(`vaccinations.catalog.${antigen}`))
.join(", "),
}),
);
}
if (entry.typicalSeriesDoses !== null) {
lines.push(
tCount("vaccinations.info.primarySeries", entry.typicalSeriesDoses, {
@@ -70,7 +86,7 @@ function infoSentences(
* empty popover for an entry the templates say nothing about.
*/
export function catalogInfoAvailable(slug: string | null): boolean {
const entry = resolveCatalogEntry(slug);
const entry = resolveVaccineEntry({ antigenSlug: slug });
if (!entry) return false;
return (
entry.typicalSeriesDoses !== null ||
@@ -79,9 +95,20 @@ export function catalogInfoAvailable(slug: string | null): boolean {
);
}
export function CatalogInfo({ slug }: { slug: string | null }) {
export function CatalogInfo({
slug = null,
entry,
}: {
slug?: string | null;
/** An already resolved entry, e.g. one of the person's own definitions. */
entry?: ResolvedVaccineEntry | null;
}) {
const { t, tCount } = useTranslations();
const info = infoSentences(slug, t, tCount);
const info = infoSentences(
entry ?? resolveVaccineEntry({ antigenSlug: slug }),
t,
tCount,
);
if (!info) return null;
return (
@@ -91,9 +118,11 @@ export function CatalogInfo({ slug }: { slug: string | null }) {
<li key={index}>{line}</li>
))}
</ul>
<p className="text-muted-foreground text-xs">
{t("vaccinations.info.sourceLabel", { source: info.source })}
</p>
{info.source ? (
<p className="text-muted-foreground text-xs">
{t("vaccinations.info.sourceLabel", { source: info.source })}
</p>
) : null}
</div>
);
}
+109 -6
View File
@@ -13,9 +13,18 @@
* The list is a static import, so the search is an in-memory filter with no
* round trip and no provider dependency. Grouped by category so a lifetime Pass
* (childhood through travel) reads in the order a person recognises.
*
* v1.42 (#1005) — the person's own vaccine definitions list above the
* catalogue under their own heading, and the popover ends in "Add own
* vaccine" for a product neither list has. Picking a definition sets
* `customVaccineId` and clears the catalogue pick, and the other way round:
* a dose names one entry, and the catalogue would win a tie anyway. The
* caller applies each pick as one change (`onChange` clears the definition,
* `onCustomChange` the slug), so two updates never race over one draft;
* clearing calls `onChange(null)` and empties both.
*/
import { useMemo, useState } from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -30,16 +39,33 @@ import {
VACCINE_CATALOG,
type VaccineSeed,
} from "@/lib/vaccinations/vaccine-catalog";
import {
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { CatalogInfo } from "./catalog-info";
import type { CustomVaccine } from "./use-vaccinations";
export function CatalogPicker({
value,
onChange,
customValue = null,
customs = [],
onCustomChange,
onAddCustom,
disabled,
}: {
/** The chosen catalogue slug, or null. */
value: string | null;
/** A catalogue pick, or `null` to clear every pick. */
onChange: (slug: string | null) => void;
/** The chosen own definition's id, or null. */
customValue?: string | null;
/** The record's own definitions, listed above the catalogue. */
customs?: readonly CustomVaccine[];
onCustomChange?: (id: string | null) => void;
/** Opens the definition sheet; absent when the caller may not add one. */
onAddCustom?: () => void;
disabled?: boolean;
}) {
const { t } = useTranslations();
@@ -63,9 +89,28 @@ export function CatalogPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term, t]);
const customMatches = useMemo(() => {
const needle = term.trim().toLowerCase();
return customs.filter(
(custom) => !needle || custom.name.toLowerCase().includes(needle),
);
}, [customs, term]);
const selected: VaccineSeed | undefined = value
? VACCINE_CATALOG.find((entry) => entry.slug === value)
: undefined;
const selectedCustom =
!selected && customValue
? (customs.find((custom) => custom.id === customValue) ?? null)
: null;
const customEntry = selectedCustom
? resolveVaccineEntry(
{ customVaccineId: selectedCustom.id },
customLookupOf([selectedCustom]),
)
: null;
const hasPick = Boolean(selected || selectedCustom);
const nothingMatches = matches.length === 0 && customMatches.length === 0;
return (
<div className="space-y-2">
@@ -89,12 +134,14 @@ export function CatalogPicker({
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
hasPick ? "text-foreground" : "text-muted-foreground",
)}
>
{selected
? name(selected.slug)
: t("vaccinations.form.catalogNone")}
: selectedCustom
? selectedCustom.name
: t("vaccinations.form.catalogNone")}
</span>
<ChevronsUpDown
className="size-4 shrink-0 opacity-50"
@@ -111,7 +158,45 @@ export function CatalogPicker({
data-slot="vaccination-catalog-search"
/>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
{matches.length === 0 ? (
{customMatches.length > 0 ? (
<>
<p className="text-muted-foreground px-2 pt-1 text-xs font-medium tracking-wide uppercase">
{t("vaccinations.custom.title")}
</p>
{customMatches.map((custom) => (
<button
key={custom.id}
type="button"
data-slot="vaccination-custom-option"
data-custom-vaccine-id={custom.id}
onClick={() => {
onCustomChange?.(custom.id);
setOpen(false);
}}
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
>
<Check
className={cn(
"size-4 shrink-0",
custom.id === customValue && !selected
? "opacity-100"
: "opacity-0",
)}
aria-hidden
/>
<span className="text-foreground min-w-0 flex-1 truncate">
{custom.name}
</span>
</button>
))}
{matches.length > 0 ? (
<p className="text-muted-foreground px-2 pt-2 text-xs font-medium tracking-wide uppercase">
{t("vaccinations.custom.catalogGroup")}
</p>
) : null}
</>
) : null}
{nothingMatches ? (
<p className="text-muted-foreground px-2 py-3 text-sm">
{t("vaccinations.form.catalogNoMatch")}
</p>
@@ -142,10 +227,26 @@ export function CatalogPicker({
))
)}
</div>
{onAddCustom ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-1 min-h-11 w-full justify-start"
data-slot="vaccination-custom-add"
onClick={() => {
setOpen(false);
onAddCustom();
}}
>
<Plus className="size-4" aria-hidden />
{t("vaccinations.custom.add")}
</Button>
) : null}
</PopoverContent>
</Popover>
{selected ? (
{hasPick ? (
<Button
type="button"
variant="ghost"
@@ -159,8 +260,10 @@ export function CatalogPicker({
) : null}
</div>
{/* Rung 1: the catalogue's sourced sentences for the chosen entry. */}
{/* Rung 1: the catalogue's sourced sentences for the chosen entry, or
the person's own definition read back in the same words. */}
{selected ? <CatalogInfo slug={selected.slug} /> : null}
{customEntry ? <CatalogInfo entry={customEntry} /> : null}
</div>
);
}
@@ -0,0 +1,121 @@
"use client";
/**
* v1.42 (#1005) — the record's own vaccine definitions, below the dose log.
*
* Rendered only once the record holds at least one: the way in is the dose
* form's picker ("Add own vaccine"), and an empty heading on every Impfpass
* would advertise a feature most people never need. Each row names the
* definition and reads it back in the catalogue's own sentences, so a
* definition and a catalogue entry describe themselves the same way. A row
* opens the definition for someone who may edit it.
*/
import { useState } from "react";
import { Plus, Syringe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { SectionHeading } from "@/components/ui/section-heading";
import { useTranslations } from "@/lib/i18n/context";
import {
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import { CatalogInfo } from "./catalog-info";
import { CustomVaccineSheet } from "./custom-vaccine-sheet";
import type { CustomVaccine } from "./use-vaccinations";
export function CustomVaccineList({
customs,
canAdd,
canManage,
}: {
customs: readonly CustomVaccine[];
/** WRITE on `profile`: may add a definition. */
canAdd: boolean;
/** MANAGE on `profile`: may edit and remove one. */
canManage: boolean;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<CustomVaccine | null>(null);
const [session, setSession] = useState(0);
if (customs.length === 0) return null;
const openSheet = (row: CustomVaccine | null) => {
setEditing(row);
setSession((n) => n + 1);
setOpen(true);
};
return (
<section className="space-y-4" data-slot="custom-vaccine-section">
<SectionHeading
icon={Syringe}
title={t("vaccinations.custom.title")}
subtitle={t("vaccinations.custom.sectionDescription")}
action={
canAdd ? (
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="custom-vaccine-add"
onClick={() => openSheet(null)}
>
<Plus className="size-4" aria-hidden />
{t("vaccinations.custom.add")}
</Button>
) : null
}
/>
<div className="space-y-2">
{customs.map((custom) => {
const entry = resolveVaccineEntry(
{ customVaccineId: custom.id },
customLookupOf([custom]),
);
const editable = canManage;
return (
<Card
key={custom.id}
className="hover:bg-muted/40 gap-0 transition-colors"
data-slot="custom-vaccine-row"
data-custom-vaccine-id={custom.id}
role={editable ? "button" : undefined}
tabIndex={editable ? 0 : undefined}
onClick={editable ? () => openSheet(custom) : undefined}
onKeyDown={
editable
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openSheet(custom);
}
}
: undefined
}
>
<CardContent className="space-y-1 px-4 py-3">
<p className="text-foreground text-sm font-medium">
{custom.name}
</p>
<CatalogInfo entry={entry} />
</CardContent>
</Card>
);
})}
</div>
<CustomVaccineSheet
key={session}
open={open}
onOpenChange={setOpen}
customVaccine={editing}
canRemove={canManage}
/>
</section>
);
}
@@ -0,0 +1,332 @@
"use client";
/**
* v1.42 (#1005) — define, edit or remove one of the record's own vaccines.
*
* A definition is a product the shipped catalogue does not list: a name, the
* diseases it protects against (the catalogue's own antigen list, because the
* series and the booster match key on it), and optionally the series length
* and the booster interval. A dose logged against it then counts like a
* catalogue pick (`resolve-vaccine-entry.ts`).
*
* Save needs a name and at least one disease; nothing else is required.
* Removing asks first and says what happens to the doses: they stay, under
* the definition's name.
*
* Capability note: the same posture as the dose sheet. Create needs WRITE on
* `profile`, edit and remove need MANAGE; the mounts ask before opening it.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { ANTIGEN_SLUGS } from "@/lib/vaccinations/vaccine-catalog";
import {
useCustomVaccineMutations,
type CustomVaccine,
type CustomVaccineWriteBody,
} from "./use-vaccinations";
export interface CustomVaccineDraft {
name: string;
components: string[];
typicalSeriesDoses: string;
boosterIntervalMonths: string;
}
export function customVaccineDraft(
row: CustomVaccine | null,
): CustomVaccineDraft {
return {
name: row?.name ?? "",
components: row ? [...row.components] : [],
typicalSeriesDoses: row?.typicalSeriesDoses?.toString() ?? "",
boosterIntervalMonths: row?.boosterIntervalMonths?.toString() ?? "",
};
}
function toInt(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number.parseInt(trimmed, 10);
return Number.isNaN(n) ? null : n;
}
/** The body a draft becomes; null while it cannot be saved. */
export function customVaccineBody(
draft: CustomVaccineDraft,
): CustomVaccineWriteBody | null {
const name = draft.name.trim();
if (!name || draft.components.length === 0) return null;
return {
name,
// Catalogue order, so the same choice always reads the same way.
components: ANTIGEN_SLUGS.filter((slug) => draft.components.includes(slug)),
typicalSeriesDoses: toInt(draft.typicalSeriesDoses),
boosterIntervalMonths: toInt(draft.boosterIntervalMonths),
};
}
export function CustomVaccineSheet({
open,
onOpenChange,
customVaccine,
canRemove = false,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** `null` creates a definition; a row edits it. */
customVaccine: CustomVaccine | null;
/** Offer the removal; the caller asks for MANAGE first. */
canRemove?: boolean;
/** Raised with the saved definition, e.g. to pick it in the dose form. */
onSaved?: (saved: CustomVaccine) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useCustomVaccineMutations();
// Seeded once at mount; the caller remounts on every open by varying `key`.
const [draft, setDraft] = useState<CustomVaccineDraft>(() =>
customVaccineDraft(customVaccine),
);
const [error, setError] = useState<string | null>(null);
const [confirmingRemove, setConfirmingRemove] = useState(false);
// The diseases an edit opens with lead the list, so what the definition
// covers is visible without scrolling; the order is fixed at mount so a
// box never jumps away from the pointer that ticked it.
const [antigenOrder] = useState<readonly string[]>(() => {
const chosen = new Set(customVaccine?.components ?? []);
return [
...ANTIGEN_SLUGS.filter((slug) => chosen.has(slug)),
...ANTIGEN_SLUGS.filter((slug) => !chosen.has(slug)),
];
});
const isEdit = customVaccine !== null;
const pending = create.isPending || update.isPending;
const body = customVaccineBody(draft);
const patch = (part: Partial<CustomVaccineDraft>) =>
setDraft((current) => ({ ...current, ...part }));
const toggle = (antigen: string, on: boolean) =>
setDraft((current) => ({
...current,
components: on
? [...current.components.filter((a) => a !== antigen), antigen]
: current.components.filter((a) => a !== antigen),
}));
const submit = async () => {
if (!body) return;
setError(null);
try {
const saved = isEdit
? await update.mutateAsync({ id: customVaccine.id, body })
: await create.mutateAsync(body);
toastWrittenOutcome("success", t("vaccinations.custom.saved"));
onOpenChange(false);
onSaved?.(saved);
} catch (err) {
const message =
err instanceof ApiError &&
err.meta?.errorCode === "vaccination.custom.name-taken"
? t("vaccinations.custom.nameTaken")
: t("vaccinations.custom.saveFailed");
setError(message);
toast.error(message);
}
};
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t(
isEdit ? "vaccinations.custom.editTitle" : "vaccinations.custom.add",
)}
description={t("vaccinations.custom.description")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="min-h-11"
data-slot="custom-vaccine-save"
disabled={!body || pending}
onClick={() => void submit()}
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4" data-slot="custom-vaccine-form">
<FieldGroup
htmlFor="custom-vaccine-name"
label={t("vaccinations.custom.name")}
>
<Input
id="custom-vaccine-name"
value={draft.name}
maxLength={100}
onChange={(event) => patch({ name: event.target.value })}
data-slot="custom-vaccine-name"
/>
</FieldGroup>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("vaccinations.custom.components")}
</legend>
<p className="text-muted-foreground text-xs">
{t("vaccinations.custom.componentsHint")}
</p>
<div
className="grid max-h-56 grid-cols-1 gap-x-3 overflow-y-auto overscroll-contain rounded-md border p-2 sm:grid-cols-2"
data-slot="custom-vaccine-components"
>
{antigenOrder.map((antigen) => {
const id = `custom-vaccine-antigen-${antigen}`;
const checked = draft.components.includes(antigen);
return (
<label
key={antigen}
htmlFor={id}
className="hover:bg-muted/60 flex min-h-11 cursor-pointer items-center gap-2 rounded-md px-2 text-sm"
>
<Checkbox
id={id}
checked={checked}
data-antigen={antigen}
onCheckedChange={(next) => toggle(antigen, next === true)}
/>
<span className="text-foreground min-w-0 flex-1">
{t(`vaccinations.catalog.${antigen}`)}
</span>
</label>
);
})}
</div>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup
htmlFor="custom-vaccine-series"
label={t("vaccinations.custom.seriesDoses")}
>
<Input
id="custom-vaccine-series"
type="number"
inputMode="numeric"
min={1}
max={10}
value={draft.typicalSeriesDoses}
onChange={(event) =>
patch({ typicalSeriesDoses: event.target.value })
}
/>
</FieldGroup>
<FieldGroup
htmlFor="custom-vaccine-booster"
label={t("vaccinations.custom.boosterMonths")}
hint={t("vaccinations.custom.boosterHint")}
>
<Input
id="custom-vaccine-booster"
type="number"
inputMode="numeric"
min={1}
max={600}
value={draft.boosterIntervalMonths}
onChange={(event) =>
patch({ boosterIntervalMonths: event.target.value })
}
/>
</FieldGroup>
</div>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
{isEdit && canRemove ? (
<AlertDialog
open={confirmingRemove}
onOpenChange={setConfirmingRemove}
>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11"
data-slot="custom-vaccine-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("vaccinations.custom.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("vaccinations.custom.deleteConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(customVaccine.id, {
onSuccess: () => {
toastWrittenOutcome(
"success",
t("vaccinations.custom.deleted"),
);
onOpenChange(false);
},
onError: () =>
toast.error(t("vaccinations.custom.saveFailed")),
});
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -23,12 +23,14 @@ import {
vaccinationDependentKeys,
} from "@/lib/query-keys";
import type {
CustomVaccineDTO,
VaccinationDTO,
VaccinationListDTO,
} from "@/lib/vaccinations/dto";
import type { VaccinationSuggestionResult } from "@/lib/vaccinations/document-suggestion";
export type Vaccination = VaccinationDTO;
export type CustomVaccine = CustomVaccineDTO;
/**
* The write body — every field optional but the identity pair and the date.
@@ -44,6 +46,7 @@ export interface VaccinationWriteBody {
site?: string | null;
practitionerId?: string | null;
encounterId?: string | null;
customVaccineId?: string | null;
note?: string | null;
documentIds?: string[];
}
@@ -212,3 +215,59 @@ export function useBoosterMint() {
]),
});
}
/** What a definition write carries; `userId` is never here. */
export interface CustomVaccineWriteBody {
name?: string;
components?: string[];
typicalSeriesDoses?: number | null;
boosterIntervalMonths?: number | null;
}
/** v1.42 (#1005) — the record's own vaccine definitions, by name. */
export function useCustomVaccines(enabled = true) {
return useQuery({
queryKey: queryKeys.customVaccines(),
enabled,
queryFn: () => apiGet<CustomVaccine[]>(`${BASE}/custom`),
});
}
/**
* Add, edit and remove a definition. Every write evicts the whole
* vaccinations root: an edit re-reads the series of every dose that names
* the definition, and a removal lets those doses go and names them, which
* the document sheet's dose chips show too. No reminder moves — a definition
* write never runs the satisfy matcher — so the reminder reads and the daily
* reads they feed are left alone.
*/
export function useCustomVaccineMutations() {
const qc = useQueryClient();
const invalidate = () =>
invalidateKeys(qc, [queryKeys.vaccinations(), queryKeys.documents()]);
const create = useMutation({
mutationKey: queryKeys.customVaccineCreate(),
mutationFn: (body: CustomVaccineWriteBody) =>
apiPost<CustomVaccine>(`${BASE}/custom`, body),
onSuccess: invalidate,
});
const update = useMutation({
mutationKey: queryKeys.customVaccineUpdate(),
mutationFn: ({ id, body }: { id: string; body: CustomVaccineWriteBody }) =>
apiPatch<CustomVaccine>(`${BASE}/custom/${encodeURIComponent(id)}`, body),
onSuccess: invalidate,
});
const remove = useMutation({
mutationKey: queryKeys.customVaccineDelete(),
mutationFn: (id: string) =>
apiDelete<{ deleted: boolean }>(
`${BASE}/custom/${encodeURIComponent(id)}`,
),
onSuccess: invalidate,
});
return { create, update, remove };
}
@@ -10,9 +10,11 @@
* feature exists for. The only thing that can block Save is a missing date or
* neither identity arm.
*
* The two identity arms are independent: picking a catalogue entry never
* The identity arms are independent: picking a catalogue entry never
* overwrites what the person typed, and typing never clears a pick. Either is
* sufficient; both are allowed.
* sufficient; both are allowed. Since v1.42 (#1005) the pick may also be one
* of the record's own vaccine definitions, chosen in the same picker, and a
* new definition can be added from it without leaving the form.
*
* Follows the encounter form's controlled-draft shape rather than a
* react-hook-form instance — the sibling surface this plan cites as its
@@ -32,8 +34,14 @@ import { EncounterSuggestionField } from "@/components/encounters/encounter-sugg
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 {
useCustomVaccines,
type CustomVaccine,
type Vaccination,
type VaccinationWriteBody,
} from "./use-vaccinations";
import { CatalogPicker } from "./catalog-picker";
import { CustomVaccineSheet } from "./custom-vaccine-sheet";
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only";
@@ -52,6 +60,8 @@ export interface VaccinationDraft {
/** `YYYY-MM-DD`, the `DateField` contract. */
occurredAt: string;
antigenSlug: string | null;
/** One of the record's own vaccine definitions, or null. */
customVaccineId: string | null;
vaccineName: string;
doseNumber: string;
seriesDoses: string;
@@ -85,6 +95,7 @@ export function emptyDraft(
return {
occurredAt: todayLocal(),
antigenSlug: null,
customVaccineId: null,
vaccineName: "",
doseNumber: "",
seriesDoses: "",
@@ -102,6 +113,8 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft {
return {
occurredAt: row.occurredAt.slice(0, 10),
antigenSlug: row.antigenSlug,
// A definition removed since stays unpicked: the picker cannot name it.
customVaccineId: row.customVaccine ? row.customVaccineId : null,
vaccineName: row.vaccineName ?? "",
doseNumber: row.doseNumber?.toString() ?? "",
seriesDoses: row.seriesDoses?.toString() ?? "",
@@ -125,7 +138,11 @@ export function draftInstant(day: string): string | null {
/** Does the draft carry at least one identity arm? */
export function draftHasIdentity(draft: VaccinationDraft): boolean {
return draft.antigenSlug !== null || draft.vaccineName.trim().length > 0;
return (
draft.antigenSlug !== null ||
draft.customVaccineId !== null ||
draft.vaccineName.trim().length > 0
);
}
function toInt(value: string): number | null {
@@ -145,6 +162,7 @@ export function draftToBody(
return {
occurredAt,
antigenSlug: draft.antigenSlug,
customVaccineId: draft.customVaccineId,
vaccineName: draft.vaccineName.trim() || null,
doseNumber: toInt(draft.doseNumber),
seriesDoses: toInt(draft.seriesDoses),
@@ -186,6 +204,11 @@ export function VaccinationForm({
const [seriesOpen, setSeriesOpen] = useState(
draft.doseNumber.length > 0 || draft.seriesDoses.length > 0,
);
const customs = useCustomVaccines();
// The definition sheet, opened from the picker. Bumped `session` remounts
// it so a second open starts empty.
const [addingCustom, setAddingCustom] = useState(false);
const [customSession, setCustomSession] = useState(0);
const anchor = draftInstant(draft.occurredAt);
@@ -212,10 +235,32 @@ export function VaccinationForm({
>
<CatalogPicker
value={draft.antigenSlug}
onChange={(slug) => patch({ antigenSlug: slug })}
// One change per pick, so the two arms never race over one draft.
onChange={(slug) =>
patch({ antigenSlug: slug, customVaccineId: null })
}
customValue={draft.customVaccineId}
customs={customs.data ?? []}
onCustomChange={(id) =>
patch({ customVaccineId: id, antigenSlug: null })
}
onAddCustom={() => {
setCustomSession((n) => n + 1);
setAddingCustom(true);
}}
/>
</FieldGroup>
<CustomVaccineSheet
key={customSession}
open={addingCustom}
onOpenChange={setAddingCustom}
customVaccine={null}
onSaved={(saved: CustomVaccine) =>
patch({ customVaccineId: saved.id, antigenSlug: null })
}
/>
<FieldGroup
htmlFor="vaccination-free-text"
label={t("vaccinations.form.freeTextLabel")}
@@ -13,6 +13,10 @@
* duplication: one record, several appearances, and the numbers come straight
* from the DTO's `series` array, never recomputed here.
*
* A dose logged against the person's own vaccine definition groups exactly
* like a catalogue pick, under each antigen the definition lists, with the
* definition's name as its marker.
*
* A free-text-only record, and a record whose slug the catalogue no longer
* resolves, carries an empty `series` and groups under its verbatim
* `vaccineName` instead — the degrade the DTO's `catalogEntry: null` signals,
@@ -72,7 +76,9 @@ interface AntigenGroup {
function recordIdentity(t: Translate, record: Vaccination): string | null {
if (record.catalogEntry)
return t(`vaccinations.catalog.${record.catalogEntry.slug}`);
return record.vaccineName;
// The person's own definition names the dose ahead of its free text, the
// way a catalogue pick does.
return record.customVaccine?.name ?? record.vaccineName;
}
/** The series sentence for one appearance, composed from resolved numbers. */
@@ -128,7 +134,11 @@ function groupByAntigen(records: readonly Vaccination[]): AntigenGroup[] {
// No resolvable antigen: a free-text row or a dead slug. Its verbatim name
// is the heading; the slug is the last-resort key so a nameless dead-slug
// row still lands somewhere rather than colliding with another.
const name = record.vaccineName ?? record.antigenSlug ?? "—";
const name =
record.vaccineName ??
record.customVaccine?.name ??
record.antigenSlug ??
"—";
const key = `free:${name}`;
ensure(key, { key, antigen: null, freeName: name }).rows.push({
record,
@@ -26,7 +26,12 @@ import { useTranslations } from "@/lib/i18n/context";
import { VaccinationList } from "./vaccination-list";
import { VaccinationSheet } from "./vaccination-sheet";
import { BoosterMintPrompt, boosterOfferFor } from "./booster-mint-prompt";
import { useVaccinations, type Vaccination } from "./use-vaccinations";
import { CustomVaccineList } from "./custom-vaccine-list";
import {
useCustomVaccines,
useVaccinations,
type Vaccination,
} from "./use-vaccinations";
export function VaccinationsView() {
const { t } = useTranslations();
@@ -45,6 +50,10 @@ export function VaccinationsView() {
const canPlanBooster = canWriteDomain("measurements");
const { data, isLoading, isError, refetch } = useVaccinations();
const records = data?.vaccinations ?? [];
// v1.42 (#1005) — the record's own definitions, listed below the log once
// there is one. A failed read leaves the section out rather than claiming
// there are none; the picker reads the same query and says the same.
const customs = useCustomVaccines();
// One sheet for create and edit. Bumped `session` remounts it so a second
// open never shows the first's values.
@@ -154,6 +163,12 @@ export function VaccinationsView() {
}
/>
)}
<CustomVaccineList
customs={customs.data ?? []}
canAdd={canAddDose}
canManage={canManageProfile}
/>
</div>
);
}