feat(vaccinations): add the dose capture and edit form

- one sheet for create and edit, saving with a date and one identity arm
  (catalogue pick or free text); nothing else can block the save
- catalogue picker searches localised names and generic synonyms over the
  static antigen list, never overwriting the free-text arm
- optional dose/series, lot, site, practice combobox, visit suggestion and
  note, all reusing the encounter-form primitives
- write hooks fan out through the dose dependent-keys and the reminder reads
- form/site/sheet copy in six locales
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 20:40:40 +02:00
1 parent 1d07204293
commit ff72917833
14 files changed
+1056 -13

No files matched your search

+37
View File
@@ -8527,6 +8527,43 @@
"row": {
"lot": "Charge {lot}"
},
"form": {
"date": "Datum",
"catalogLabel": "Impfstoff (Katalog)",
"identityHint": "Katalog wählen oder unten frei eintragen — eins genügt.",
"catalogNone": "Impfstoff wählen",
"catalogSearch": "Impfstoff suchen",
"catalogNoMatch": "Kein Katalogeintrag gefunden",
"catalogClear": "Auswahl entfernen",
"freeTextLabel": "Freitext",
"freeTextPlaceholder": "wie im Impfpass angegeben",
"seriesToggle": "Serie angeben",
"doseNumber": "Dosis-Nr.",
"seriesDoses": "Dosen in Serie",
"lot": "Chargennummer",
"site": "Impfstelle",
"siteNone": "Keine Angabe",
"practitioner": "Praxis / Arzt",
"note": "Notiz"
},
"site": {
"LEFT_ARM": "Linker Oberarm",
"RIGHT_ARM": "Rechter Oberarm",
"LEFT_THIGH": "Linker Oberschenkel",
"RIGHT_THIGH": "Rechter Oberschenkel",
"ORAL": "Oral",
"NASAL": "Nasal",
"OTHER": "Andere"
},
"createTitle": "Impfung erfassen",
"editTitle": "Impfung bearbeiten",
"formDescription": "Datum und Impfstoff genügen; alles andere ist optional.",
"created": "Impfung gespeichert",
"updated": "Impfung aktualisiert",
"saveFailed": "Impfung konnte nicht gespeichert werden.",
"deleteTitle": "Impfung löschen?",
"deleteDescription": "Der Eintrag wird aus deinem Impfpass entfernt. Ein geplanter Auffrischungs-Reminder bleibt bestehen.",
"deleteFailed": "Impfung konnte nicht gelöscht werden.",
"empty": {
"title": "Noch keine Impfung erfasst",
"description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen."
+37
View File
@@ -8527,6 +8527,43 @@
"row": {
"lot": "Lot {lot}"
},
"form": {
"date": "Date",
"catalogLabel": "Vaccine (catalogue)",
"identityHint": "Pick from the catalogue or type it in below — either is enough.",
"catalogNone": "Choose a vaccine",
"catalogSearch": "Search vaccines",
"catalogNoMatch": "No catalogue entry found",
"catalogClear": "Clear selection",
"freeTextLabel": "Free text",
"freeTextPlaceholder": "as written on the record",
"seriesToggle": "Add series details",
"doseNumber": "Dose no.",
"seriesDoses": "Doses in series",
"lot": "Lot number",
"site": "Injection site",
"siteNone": "Not specified",
"practitioner": "Practice / doctor",
"note": "Note"
},
"site": {
"LEFT_ARM": "Left upper arm",
"RIGHT_ARM": "Right upper arm",
"LEFT_THIGH": "Left thigh",
"RIGHT_THIGH": "Right thigh",
"ORAL": "Oral",
"NASAL": "Nasal",
"OTHER": "Other"
},
"createTitle": "Log a vaccination",
"editTitle": "Edit vaccination",
"formDescription": "A date and the vaccine are enough; everything else is optional.",
"created": "Vaccination saved",
"updated": "Vaccination updated",
"saveFailed": "The vaccination could not be saved.",
"deleteTitle": "Delete this vaccination?",
"deleteDescription": "The entry is removed from your record. A planned booster reminder stays.",
"deleteFailed": "The vaccination could not be deleted.",
"empty": {
"title": "No vaccination logged yet",
"description": "Transcribe your vaccination card line by line — a date and the vaccine are enough."
+37
View File
@@ -8527,6 +8527,43 @@
"row": {
"lot": "Lote {lot}"
},
"form": {
"date": "Fecha",
"catalogLabel": "Vacuna (catálogo)",
"identityHint": "Elige del catálogo o escríbela abajo: basta con una.",
"catalogNone": "Elegir una vacuna",
"catalogSearch": "Buscar vacunas",
"catalogNoMatch": "No se encontró ninguna entrada del catálogo",
"catalogClear": "Quitar selección",
"freeTextLabel": "Texto libre",
"freeTextPlaceholder": "tal como figura en la cartilla",
"seriesToggle": "Añadir datos de la serie",
"doseNumber": "N.º de dosis",
"seriesDoses": "Dosis en la serie",
"lot": "Número de lote",
"site": "Lugar de aplicación",
"siteNone": "Sin especificar",
"practitioner": "Consulta / médico",
"note": "Nota"
},
"site": {
"LEFT_ARM": "Brazo izquierdo",
"RIGHT_ARM": "Brazo derecho",
"LEFT_THIGH": "Muslo izquierdo",
"RIGHT_THIGH": "Muslo derecho",
"ORAL": "Oral",
"NASAL": "Nasal",
"OTHER": "Otro"
},
"createTitle": "Registrar una vacuna",
"editTitle": "Editar vacuna",
"formDescription": "Bastan una fecha y la vacuna; todo lo demás es opcional.",
"created": "Vacuna guardada",
"updated": "Vacuna actualizada",
"saveFailed": "No se pudo guardar la vacuna.",
"deleteTitle": "¿Eliminar esta vacuna?",
"deleteDescription": "La entrada se elimina de tu registro. Un recordatorio de refuerzo programado se mantiene.",
"deleteFailed": "No se pudo eliminar la 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."
+37
View File
@@ -8527,6 +8527,43 @@
"row": {
"lot": "Lot {lot}"
},
"form": {
"date": "Date",
"catalogLabel": "Vaccin (catalogue)",
"identityHint": "Choisissez dans le catalogue ou saisissez ci-dessous : l'un suffit.",
"catalogNone": "Choisir un vaccin",
"catalogSearch": "Rechercher des vaccins",
"catalogNoMatch": "Aucune entrée du catalogue trouvée",
"catalogClear": "Effacer la sélection",
"freeTextLabel": "Texte libre",
"freeTextPlaceholder": "comme indiqué sur le carnet",
"seriesToggle": "Ajouter les détails de la série",
"doseNumber": "N° de dose",
"seriesDoses": "Doses dans la série",
"lot": "Numéro de lot",
"site": "Site d'injection",
"siteNone": "Non précisé",
"practitioner": "Cabinet / médecin",
"note": "Note"
},
"site": {
"LEFT_ARM": "Bras gauche",
"RIGHT_ARM": "Bras droit",
"LEFT_THIGH": "Cuisse gauche",
"RIGHT_THIGH": "Cuisse droite",
"ORAL": "Oral",
"NASAL": "Nasal",
"OTHER": "Autre"
},
"createTitle": "Enregistrer une vaccination",
"editTitle": "Modifier la vaccination",
"formDescription": "Une date et le vaccin suffisent ; tout le reste est facultatif.",
"created": "Vaccination enregistrée",
"updated": "Vaccination mise à jour",
"saveFailed": "La vaccination n'a pas pu être enregistrée.",
"deleteTitle": "Supprimer cette vaccination ?",
"deleteDescription": "L'entrée est retirée de votre carnet. Un rappel programmé est conservé.",
"deleteFailed": "La vaccination n'a pas pu être supprimée.",
"empty": {
"title": "Aucune vaccination enregistrée",
"description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent."
+37
View File
@@ -8527,6 +8527,43 @@
"row": {
"lot": "Lotto {lot}"
},
"form": {
"date": "Data",
"catalogLabel": "Vaccino (catalogo)",
"identityHint": "Scegli dal catalogo o scrivilo sotto: ne basta uno.",
"catalogNone": "Scegli un vaccino",
"catalogSearch": "Cerca vaccini",
"catalogNoMatch": "Nessuna voce del catalogo trovata",
"catalogClear": "Rimuovi selezione",
"freeTextLabel": "Testo libero",
"freeTextPlaceholder": "come indicato sul libretto",
"seriesToggle": "Aggiungi dettagli della serie",
"doseNumber": "N. dose",
"seriesDoses": "Dosi nella serie",
"lot": "Numero di lotto",
"site": "Sede di iniezione",
"siteNone": "Non specificato",
"practitioner": "Studio / medico",
"note": "Nota"
},
"site": {
"LEFT_ARM": "Braccio sinistro",
"RIGHT_ARM": "Braccio destro",
"LEFT_THIGH": "Coscia sinistra",
"RIGHT_THIGH": "Coscia destra",
"ORAL": "Orale",
"NASAL": "Nasale",
"OTHER": "Altro"
},
"createTitle": "Registra una vaccinazione",
"editTitle": "Modifica vaccinazione",
"formDescription": "Bastano una data e il vaccino; tutto il resto è facoltativo.",
"created": "Vaccinazione salvata",
"updated": "Vaccinazione aggiornata",
"saveFailed": "Impossibile salvare la vaccinazione.",
"deleteTitle": "Eliminare questa vaccinazione?",
"deleteDescription": "La voce viene rimossa dal tuo libretto. Un promemoria di richiamo programmato rimane.",
"deleteFailed": "Impossibile eliminare la vaccinazione.",
"empty": {
"title": "Nessuna vaccinazione registrata",
"description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino."
+37
View File
@@ -8527,6 +8527,43 @@
"row": {
"lot": "Seria {lot}"
},
"form": {
"date": "Data",
"catalogLabel": "Szczepionka (katalog)",
"identityHint": "Wybierz z katalogu lub wpisz poniżej — wystarczy jedno.",
"catalogNone": "Wybierz szczepionkę",
"catalogSearch": "Szukaj szczepionek",
"catalogNoMatch": "Nie znaleziono wpisu w katalogu",
"catalogClear": "Wyczyść wybór",
"freeTextLabel": "Tekst dowolny",
"freeTextPlaceholder": "jak podano w karcie szczepień",
"seriesToggle": "Dodaj szczegóły serii",
"doseNumber": "Nr dawki",
"seriesDoses": "Dawki w serii",
"lot": "Numer serii",
"site": "Miejsce podania",
"siteNone": "Nie podano",
"practitioner": "Gabinet / lekarz",
"note": "Notatka"
},
"site": {
"LEFT_ARM": "Lewe ramię",
"RIGHT_ARM": "Prawe ramię",
"LEFT_THIGH": "Lewe udo",
"RIGHT_THIGH": "Prawe udo",
"ORAL": "Doustnie",
"NASAL": "Donosowo",
"OTHER": "Inne"
},
"createTitle": "Zarejestruj szczepienie",
"editTitle": "Edytuj szczepienie",
"formDescription": "Wystarczą data i szczepionka; cała reszta jest opcjonalna.",
"created": "Szczepienie zapisane",
"updated": "Szczepienie zaktualizowane",
"saveFailed": "Nie udało się zapisać szczepienia.",
"deleteTitle": "Usunąć to szczepienie?",
"deleteDescription": "Wpis zostanie usunięty z Twojej karty. Zaplanowane przypomnienie o dawce przypominającej pozostaje.",
"deleteFailed": "Nie udało się usunąć szczepienia.",
"empty": {
"title": "Nie zarejestrowano jeszcze żadnego szczepienia",
"description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka."
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import {
draftHasIdentity,
draftInstant,
draftToBody,
emptyDraft,
} from "../vaccination-form";
/**
* The form's one hard rule: a dose saves with a date and ONE identity arm — a
* catalogue pick OR the person's own wording — and nothing else. These cover
* the body derivation that the sheet's Save gate reads.
*/
describe("vaccination form draft", () => {
it("saves with a date and a catalogue pick alone", () => {
const body = draftToBody(
emptyDraft({ occurredAt: "2020-03-04", antigenSlug: "tetanus" }),
);
expect(body).not.toBeNull();
expect(body!.antigenSlug).toBe("tetanus");
expect(body!.occurredAt).toBe("2020-03-04T00:00:00.000Z");
// Nothing else is required — the optional fields default to absences.
expect(body!.vaccineName).toBeNull();
expect(body!.doseNumber).toBeNull();
expect(body!.lotNumber).toBeNull();
expect(body!.site).toBeNull();
expect(body!.practitionerId).toBeNull();
});
it("saves with a date and free text alone", () => {
const body = draftToBody(
emptyDraft({ occurredAt: "1987-06-01", vaccineName: " Old brand " }),
);
expect(body).not.toBeNull();
expect(body!.antigenSlug).toBeNull();
expect(body!.vaccineName).toBe("Old brand");
});
it("refuses a draft with neither identity arm", () => {
const draft = emptyDraft({ occurredAt: "2020-01-01" });
expect(draftHasIdentity(draft)).toBe(false);
expect(draftToBody(draft)).toBeNull();
});
it("refuses a draft with no usable date", () => {
expect(draftInstant("")).toBeNull();
expect(draftInstant("not-a-date")).toBeNull();
expect(
draftToBody(emptyDraft({ occurredAt: "", antigenSlug: "tetanus" })),
).toBeNull();
});
it("carries the optional fields through when present", () => {
const body = draftToBody(
emptyDraft({
occurredAt: "2021-09-09",
antigenSlug: "tdap",
doseNumber: "3",
seriesDoses: "3",
lotNumber: "AB123",
site: "LEFT_ARM",
note: "sore arm",
}),
);
expect(body).toMatchObject({
doseNumber: 3,
seriesDoses: 3,
lotNumber: "AB123",
site: "LEFT_ARM",
note: "sore arm",
});
});
});
@@ -0,0 +1,143 @@
"use client";
/**
* The catalogue arm of the identity pair.
*
* Searches the static antigen catalogue by its localised display name and by
* the generic synonyms shipped with each entry — never a trade name, because
* none exist in the catalogue; a person who types a brand matches nothing here
* and falls back to the free-text arm, which is exactly where a Pass's own
* wording belongs. Picking an entry sets `antigenSlug`; it never touches what
* the person typed in the free-text field.
*
* 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.
*/
import { useMemo, useState } from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
VACCINE_CATALOG,
type VaccineSeed,
} from "@/lib/vaccinations/vaccine-catalog";
export function CatalogPicker({
value,
onChange,
disabled,
}: {
/** The chosen catalogue slug, or null. */
value: string | null;
onChange: (slug: string | null) => void;
disabled?: boolean;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState("");
const name = (slug: string) => t(`vaccinations.catalog.${slug}`);
const matches = useMemo(() => {
const needle = term.trim().toLowerCase();
const scored = VACCINE_CATALOG.filter((entry) => {
if (!needle) return true;
if (name(entry.slug).toLowerCase().includes(needle)) return true;
if (entry.slug.includes(needle)) return true;
return (entry.synonyms ?? []).some((s) => s.toLowerCase().includes(needle));
});
return scored;
// `name` closes over `t`, which is stable per locale; term drives the recompute.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [term, t]);
const selected: VaccineSeed | undefined = value
? VACCINE_CATALOG.find((entry) => entry.slug === value)
: undefined;
return (
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
data-slot="vaccination-catalog-trigger"
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
>
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
)}
>
{selected ? name(selected.slug) : t("vaccinations.form.catalogNone")}
</span>
<ChevronsUpDown className="size-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={t("vaccinations.form.catalogSearch")}
aria-label={t("vaccinations.form.catalogSearch")}
data-slot="vaccination-catalog-search"
/>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
{matches.length === 0 ? (
<p className="text-muted-foreground px-2 py-3 text-sm">
{t("vaccinations.form.catalogNoMatch")}
</p>
) : (
matches.map((entry) => (
<button
key={entry.slug}
type="button"
data-slot="vaccination-catalog-option"
onClick={() => {
onChange(entry.slug);
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",
entry.slug === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="text-foreground min-w-0 flex-1 truncate">
{name(entry.slug)}
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
{selected ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0"
aria-label={t("vaccinations.form.catalogClear")}
onClick={() => onChange(null)}
>
<X className="size-4" aria-hidden />
</Button>
) : null}
</div>
);
}
@@ -12,14 +12,35 @@
* `vaccinationDependentKeys`, which evicts the preventive-care root alongside
* the dose list because logging a dose re-anchors the booster it answers.
*/
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import { invalidateKeys, queryKeys, vaccinationDependentKeys } from "@/lib/query-keys";
import type { VaccinationDTO, VaccinationListDTO } from "@/lib/vaccinations/dto";
export type Vaccination = VaccinationDTO;
/**
* The write body — every field optional but the identity pair and the date.
* `userId` is never here; the route narrows it from the session.
*/
export interface VaccinationWriteBody {
occurredAt?: string;
antigenSlug?: string | null;
vaccineName?: string | null;
doseNumber?: number | null;
seriesDoses?: number | null;
lotNumber?: string | null;
site?: string | null;
practitionerId?: string | null;
encounterId?: string | null;
note?: string | null;
documentIds?: string[];
}
const BASE = "/api/vaccinations";
/**
* The account's immunization log, newest dose first.
*
@@ -37,8 +58,56 @@ export function useVaccinations(
queryFn: () =>
apiGet<VaccinationListDTO>(
antigenSlug
? `/api/vaccinations?antigenSlug=${encodeURIComponent(antigenSlug)}`
: "/api/vaccinations",
? `${BASE}?antigenSlug=${encodeURIComponent(antigenSlug)}`
: BASE,
),
});
}
/**
* Create / edit / soft-delete / restore a dose.
*
* Every write fans out through `vaccinationDependentKeys` AND the reminder
* reads: logging a dose runs the server's satisfy matcher, which re-anchors any
* booster the dose answers, so the preventive-care list must repaint in the
* same tick — the exact pairing the encounter mutations use, through the same
* helper rather than a second sweep that could drift from it.
*/
export function useVaccinationMutations() {
const qc = useQueryClient();
const invalidate = () =>
Promise.all([
invalidateKeys(qc, vaccinationDependentKeys),
invalidateReminderReads(qc),
]);
const create = useMutation({
mutationKey: queryKeys.vaccinationCreate(),
mutationFn: (body: VaccinationWriteBody) =>
apiPost<Vaccination>(BASE, body),
onSuccess: invalidate,
});
const update = useMutation({
mutationKey: queryKeys.vaccinationUpdate(),
mutationFn: ({ id, body }: { id: string; body: VaccinationWriteBody }) =>
apiPatch<Vaccination>(`${BASE}/${id}`, body),
onSuccess: invalidate,
});
const remove = useMutation({
mutationKey: queryKeys.vaccinationDelete(),
mutationFn: (id: string) =>
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
onSuccess: invalidate,
});
const restore = useMutation({
mutationKey: queryKeys.vaccinationRestore(),
mutationFn: (id: string) =>
apiPost<Vaccination>(`${BASE}/${id}/restore`, {}),
onSuccess: invalidate,
});
return { create, update, remove, restore };
}
@@ -0,0 +1,296 @@
"use client";
/**
* The dose capture and edit form.
*
* **A dose saves with a date and ONE identity arm — a catalogue pick OR the
* person's own wording — and nothing else.** No required dose number, no
* required batch code, no required practice. A required catalogue pick would
* make a 1987 Impfpass line unloggable, which is the whole population this
* 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
* overwrites what the person typed, and typing never clears a pick. Either is
* sufficient; both are allowed.
*
* Follows the encounter form's controlled-draft shape rather than a
* react-hook-form instance — the sibling surface this plan cites as its
* precedent uses a draft, and the server's Zod schema is the validation
* authority either way. The client guard is for the two properties a person
* feels immediately: a date is present, and the vaccine is named somehow.
*/
import { useState } from "react";
import { DateField } from "@/components/ui/date-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
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 { useTranslations } from "@/lib/i18n/context";
import type { Practitioner } from "@/hooks/use-practitioners";
import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations";
import { CatalogPicker } from "./catalog-picker";
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
export const VACCINATION_SITES = [
"LEFT_ARM",
"RIGHT_ARM",
"LEFT_THIGH",
"RIGHT_THIGH",
"ORAL",
"NASAL",
"OTHER",
] as const;
export interface VaccinationDraft {
/** `YYYY-MM-DD`, the `DateField` contract. */
occurredAt: string;
antigenSlug: string | null;
vaccineName: string;
doseNumber: string;
seriesDoses: string;
lotNumber: string;
site: string;
practitioner: Practitioner | null;
encounterId: string | null;
note: string;
documentIds: string[];
}
/** Today as a local `YYYY-MM-DD`, the DateField default and max. */
export function todayLocal(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export function emptyDraft(
overrides?: Partial<VaccinationDraft>,
): VaccinationDraft {
return {
occurredAt: todayLocal(),
antigenSlug: null,
vaccineName: "",
doseNumber: "",
seriesDoses: "",
lotNumber: "",
site: "",
practitioner: null,
encounterId: null,
note: "",
documentIds: [],
...overrides,
};
}
export function draftFromVaccination(row: Vaccination): VaccinationDraft {
return {
occurredAt: row.occurredAt.slice(0, 10),
antigenSlug: row.antigenSlug,
vaccineName: row.vaccineName ?? "",
doseNumber: row.doseNumber?.toString() ?? "",
seriesDoses: row.seriesDoses?.toString() ?? "",
lotNumber: row.lotNumber ?? "",
site: row.site ?? "",
practitioner: row.practitioner,
encounterId: row.encounter?.id ?? null,
note: row.note ?? "",
documentIds: row.documents?.map((doc) => doc.id) ?? [],
};
}
/** The day, at UTC midnight — an Impfpass carries dates, never times. */
export function draftInstant(day: string): string | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
const parsed = new Date(`${day}T00:00:00.000Z`);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
/** Does the draft carry at least one identity arm? */
export function draftHasIdentity(draft: VaccinationDraft): boolean {
return draft.antigenSlug !== null || draft.vaccineName.trim().length > 0;
}
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, empties turned into absences. Null when unsavable. */
export function draftToBody(draft: VaccinationDraft): VaccinationWriteBody | null {
const occurredAt = draftInstant(draft.occurredAt);
if (!occurredAt) return null;
if (!draftHasIdentity(draft)) return null;
return {
occurredAt,
antigenSlug: draft.antigenSlug,
vaccineName: draft.vaccineName.trim() || null,
doseNumber: toInt(draft.doseNumber),
seriesDoses: toInt(draft.seriesDoses),
lotNumber: draft.lotNumber.trim() || null,
site: draft.site || null,
practitionerId: draft.practitioner?.id ?? null,
encounterId: draft.encounterId,
note: draft.note.trim() || null,
documentIds: draft.documentIds,
};
}
export function VaccinationForm({
draft,
onChange,
}: {
draft: VaccinationDraft;
onChange: (next: VaccinationDraft) => void;
}) {
const { t } = useTranslations();
const patch = (part: Partial<VaccinationDraft>) =>
onChange({ ...draft, ...part });
const [seriesOpen, setSeriesOpen] = useState(
draft.doseNumber.length > 0 || draft.seriesDoses.length > 0,
);
const anchor = draftInstant(draft.occurredAt);
return (
<div className="space-y-4" data-slot="vaccination-form">
<FieldGroup
htmlFor="vaccination-occurred-at"
label={t("vaccinations.form.date")}
>
<DateField
id="vaccination-occurred-at"
value={draft.occurredAt}
onChange={(value) => patch({ occurredAt: value })}
max={todayLocal()}
required
data-testid="vaccination-occurred-at"
/>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-catalog-trigger"
label={t("vaccinations.form.catalogLabel")}
hint={t("vaccinations.form.identityHint")}
>
<CatalogPicker
value={draft.antigenSlug}
onChange={(slug) => patch({ antigenSlug: slug })}
/>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-free-text"
label={t("vaccinations.form.freeTextLabel")}
>
<Input
id="vaccination-free-text"
value={draft.vaccineName}
onChange={(event) => patch({ vaccineName: event.target.value })}
placeholder={t("vaccinations.form.freeTextPlaceholder")}
data-slot="vaccination-free-text"
/>
</FieldGroup>
{seriesOpen ? (
<div className="grid grid-cols-2 gap-3">
<FieldGroup
htmlFor="vaccination-dose-number"
label={t("vaccinations.form.doseNumber")}
>
<Input
id="vaccination-dose-number"
type="number"
inputMode="numeric"
min={1}
max={20}
value={draft.doseNumber}
onChange={(event) => patch({ doseNumber: event.target.value })}
/>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-series-doses"
label={t("vaccinations.form.seriesDoses")}
>
<Input
id="vaccination-series-doses"
type="number"
inputMode="numeric"
min={1}
max={10}
value={draft.seriesDoses}
onChange={(event) => patch({ seriesDoses: event.target.value })}
/>
</FieldGroup>
</div>
) : (
<button
type="button"
data-slot="vaccination-series-reveal"
className="text-muted-foreground hover:text-foreground text-sm underline-offset-2 hover:underline"
onClick={() => setSeriesOpen(true)}
>
{t("vaccinations.form.seriesToggle")}
</button>
)}
<FieldGroup htmlFor="vaccination-lot" label={t("vaccinations.form.lot")}>
<Input
id="vaccination-lot"
value={draft.lotNumber}
onChange={(event) => patch({ lotNumber: event.target.value })}
/>
</FieldGroup>
<FieldGroup htmlFor="vaccination-site" label={t("vaccinations.form.site")}>
<NativeSelect
id="vaccination-site"
value={draft.site}
onChange={(event) => patch({ site: event.target.value })}
>
<option value="">{t("vaccinations.form.siteNone")}</option>
{VACCINATION_SITES.map((site) => (
<option key={site} value={site}>
{t(`vaccinations.site.${site}`)}
</option>
))}
</NativeSelect>
</FieldGroup>
<FieldGroup
htmlFor="vaccination-practitioner"
label={t("vaccinations.form.practitioner")}
>
<PractitionerCombobox
value={draft.practitioner?.id ?? null}
selected={draft.practitioner}
onChange={(next) => patch({ practitioner: next })}
/>
</FieldGroup>
<EncounterSuggestionField
anchor={anchor}
value={draft.encounterId}
onChange={(id) => patch({ encounterId: id })}
slot="vaccination-encounter-suggestion"
/>
<FieldGroup htmlFor="vaccination-note" label={t("vaccinations.form.note")}>
<Textarea
id="vaccination-note"
rows={2}
value={draft.note}
onChange={(event) => patch({ note: event.target.value })}
/>
</FieldGroup>
</div>
);
}
@@ -129,7 +129,13 @@ function groupByAntigen(records: readonly Vaccination[]): AntigenGroup[] {
});
}
function DoseRow({ row }: { row: GroupRow }) {
function DoseRow({
row,
onEdit,
}: {
row: GroupRow;
onEdit?: (record: Vaccination) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const { record, position } = row;
@@ -143,9 +149,22 @@ function DoseRow({ row }: { row: GroupRow }) {
return (
<Card
className="gap-0"
className="hover:bg-muted/40 gap-0 transition-colors"
data-slot="vaccination-row"
data-vaccination-id={record.id}
onClick={onEdit ? () => onEdit(record) : undefined}
role={onEdit ? "button" : undefined}
tabIndex={onEdit ? 0 : undefined}
onKeyDown={
onEdit
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onEdit(record);
}
}
: undefined
}
>
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
<div className="min-w-0 space-y-0.5">
@@ -176,7 +195,13 @@ function DoseRow({ row }: { row: GroupRow }) {
);
}
export function VaccinationList({ records }: { records: readonly Vaccination[] }) {
export function VaccinationList({
records,
onEdit,
}: {
records: readonly Vaccination[];
onEdit?: (record: Vaccination) => void;
}) {
const { t } = useTranslations();
const groups = groupByAntigen(records);
@@ -198,7 +223,11 @@ export function VaccinationList({ records }: { records: readonly Vaccination[] }
</h2>
<div className="space-y-2">
{group.rows.map((row, index) => (
<DoseRow key={`${row.record.id}:${index}`} row={row} />
<DoseRow
key={`${row.record.id}:${index}`}
row={row}
onEdit={onEdit}
/>
))}
</div>
</section>
@@ -0,0 +1,171 @@
"use client";
/**
* The sheet the dose form lives in — create and edit, one surface.
*
* Save is disabled only when the date cannot be parsed or neither identity arm
* is present. Nothing else on the form can block it — the property the whole
* feature is checked against.
*
* On a successful create the caller may raise the booster-mint prompt; this
* sheet completes and closes regardless of what the person does with it. The
* mint is an offer, never a step.
*/
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 { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { useVaccinationMutations, type Vaccination } from "./use-vaccinations";
import {
VaccinationForm,
draftFromVaccination,
draftHasIdentity,
draftInstant,
draftToBody,
emptyDraft,
type VaccinationDraft,
} from "./vaccination-form";
export function VaccinationSheet({
open,
onOpenChange,
vaccination,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** `null` creates a dose; a row edits it. */
vaccination: Vaccination | null;
/** Raised after a fresh create, for the booster-mint offer. */
onCreated?: (created: Vaccination) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useVaccinationMutations();
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Seeded once at mount; the caller remounts on every open by varying `key`.
const [draft, setDraft] = useState<VaccinationDraft>(() =>
vaccination ? draftFromVaccination(vaccination) : emptyDraft(),
);
const [error, setError] = useState<string | null>(null);
const isEdit = vaccination !== null;
const pending = create.isPending || update.isPending;
const savable =
draftInstant(draft.occurredAt) !== null && draftHasIdentity(draft);
const submit = async () => {
const body = draftToBody(draft);
if (!body) return;
try {
if (isEdit) {
await update.mutateAsync({ id: vaccination.id, body });
toast.success(t("vaccinations.updated"));
onOpenChange(false);
} else {
const created = await create.mutateAsync(body);
toast.success(t("vaccinations.created"));
onOpenChange(false);
onCreated?.(created);
}
} catch (err) {
const message =
err instanceof Error ? err.message : t("vaccinations.saveFailed");
setError(message);
toast.error(message);
}
};
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t(isEdit ? "vaccinations.editTitle" : "vaccinations.createTitle")}
description={t("vaccinations.formDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="min-h-11"
data-slot="vaccination-save"
disabled={!savable || pending}
onClick={() => void submit()}
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<VaccinationForm draft={draft} onChange={setDraft} />
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
{isEdit ? (
<AlertDialog open={confirmingDelete} onOpenChange={setConfirmingDelete}>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11"
data-slot="vaccination-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("vaccinations.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("vaccinations.deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(vaccination.id, {
onSuccess: () => onOpenChange(false),
onError: () =>
toast.error(t("vaccinations.deleteFailed")),
});
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -10,6 +10,7 @@
* badge only — no red card, no overdue tint. It reproduces the record; it does
* not adjudicate what is due.
*/
import { useState } from "react";
import { Plus, Syringe } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -20,13 +21,31 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { VaccinationList } from "./vaccination-list";
import { useVaccinations } from "./use-vaccinations";
import { VaccinationSheet } from "./vaccination-sheet";
import { useVaccinations, type Vaccination } from "./use-vaccinations";
export function VaccinationsView() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useVaccinations();
const records = data?.vaccinations ?? [];
// One sheet for create and edit. Bumped `session` remounts it so a second
// open never shows the first's values.
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<Vaccination | null>(null);
const [session, setSession] = useState(0);
const openCreate = () => {
setEditing(null);
setSession((n) => n + 1);
setSheetOpen(true);
};
const openEdit = (record: Vaccination) => {
setEditing(record);
setSession((n) => n + 1);
setSheetOpen(true);
};
return (
<div className="space-y-6">
<PageHeader
@@ -35,13 +54,24 @@ export function VaccinationsView() {
}
description={t("vaccinations.subtitle")}
actions={
<Button className="min-h-11 sm:min-h-9" disabled>
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
}
/>
<VaccinationSheet
key={session}
open={sheetOpen}
onOpenChange={setSheetOpen}
vaccination={editing}
/>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
@@ -55,14 +85,18 @@ export function VaccinationsView() {
onRetry={() => void refetch()}
/>
) : records.length > 0 ? (
<VaccinationList records={records} />
<VaccinationList records={records} onEdit={openEdit} />
) : (
<EmptyState
icon={<Syringe className="size-6" />}
title={t("vaccinations.empty.title")}
description={t("vaccinations.empty.description")}
action={
<Button className="min-h-11 sm:min-h-9" disabled>
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add-empty"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
+5
View File
@@ -42,4 +42,9 @@ export const vaccinationKeys = {
vaccinationList: (antigenSlug?: string | null) =>
["vaccinations", "list", antigenSlug ?? null] as const,
vaccination: (id: string) => ["vaccinations", "detail", id] 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,
} as const;