mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge tickets part 2 into release/v1.42.0
# Conflicts: # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
commit
9d4a50282e
80 files changed
+3975
-818
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user