diff --git a/messages/de.json b/messages/de.json
index 5a7361957..de0906509 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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."
diff --git a/messages/en.json b/messages/en.json
index c58a68ca6..53b75d239 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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."
diff --git a/messages/es.json b/messages/es.json
index 50aa44062..fc5c67fcf 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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."
diff --git a/messages/fr.json b/messages/fr.json
index ab92772e3..30d42f649 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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."
diff --git a/messages/it.json b/messages/it.json
index 422154b39..27048479d 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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."
diff --git a/messages/pl.json b/messages/pl.json
index b8ed79922..7dfae551f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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."
diff --git a/src/components/vaccinations/__tests__/vaccination-form.test.ts b/src/components/vaccinations/__tests__/vaccination-form.test.ts
new file mode 100644
index 000000000..0d53865b5
--- /dev/null
+++ b/src/components/vaccinations/__tests__/vaccination-form.test.ts
@@ -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",
+ });
+ });
+});
diff --git a/src/components/vaccinations/catalog-picker.tsx b/src/components/vaccinations/catalog-picker.tsx
new file mode 100644
index 000000000..d330fa889
--- /dev/null
+++ b/src/components/vaccinations/catalog-picker.tsx
@@ -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 (
+
+
+
+
+
+
+ setTerm(event.target.value)}
+ placeholder={t("vaccinations.form.catalogSearch")}
+ aria-label={t("vaccinations.form.catalogSearch")}
+ data-slot="vaccination-catalog-search"
+ />
+
+ {matches.length === 0 ? (
+
+ {t("vaccinations.form.catalogNoMatch")}
+
+ ) : (
+ matches.map((entry) => (
+
+ ))
+ )}
+
+
+
+
+ {selected ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts
index 8c4ed25bc..111f238b5 100644
--- a/src/components/vaccinations/use-vaccinations.ts
+++ b/src/components/vaccinations/use-vaccinations.ts
@@ -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(
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(BASE, body),
+ onSuccess: invalidate,
+ });
+
+ const update = useMutation({
+ mutationKey: queryKeys.vaccinationUpdate(),
+ mutationFn: ({ id, body }: { id: string; body: VaccinationWriteBody }) =>
+ apiPatch(`${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(`${BASE}/${id}/restore`, {}),
+ onSuccess: invalidate,
+ });
+
+ return { create, update, remove, restore };
+}
diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx
new file mode 100644
index 000000000..e2853811a
--- /dev/null
+++ b/src/components/vaccinations/vaccination-form.tsx
@@ -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 {
+ 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) =>
+ onChange({ ...draft, ...part });
+
+ const [seriesOpen, setSeriesOpen] = useState(
+ draft.doseNumber.length > 0 || draft.seriesDoses.length > 0,
+ );
+
+ const anchor = draftInstant(draft.occurredAt);
+
+ return (
+
+ );
+}
diff --git a/src/components/vaccinations/vaccination-list.tsx b/src/components/vaccinations/vaccination-list.tsx
index 5134162a3..6374a0897 100644
--- a/src/components/vaccinations/vaccination-list.tsx
+++ b/src/components/vaccinations/vaccination-list.tsx
@@ -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 (
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
+ }
>
@@ -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[] }
{group.rows.map((row, index) => (
-
+
))}
diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx
new file mode 100644
index 000000000..a14228e02
--- /dev/null
+++ b/src/components/vaccinations/vaccination-sheet.tsx
@@ -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
(() =>
+ vaccination ? draftFromVaccination(vaccination) : emptyDraft(),
+ );
+ const [error, setError] = useState(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 (
+
+
+
+ >
+ }
+ >
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {isEdit ? (
+
+
+
+
+
+
+
+ {t("vaccinations.deleteTitle")}
+
+
+ {t("vaccinations.deleteDescription")}
+
+
+
+ {t("common.cancel")}
+ {
+ remove.mutate(vaccination.id, {
+ onSuccess: () => onOpenChange(false),
+ onError: () =>
+ toast.error(t("vaccinations.deleteFailed")),
+ });
+ }}
+ >
+ {t("common.delete")}
+
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx
index e7a4fa558..529c498f9 100644
--- a/src/components/vaccinations/vaccinations-view.tsx
+++ b/src/components/vaccinations/vaccinations-view.tsx
@@ -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(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 (
+
}
/>
+
+
{isLoading ? (
@@ -55,14 +85,18 @@ export function VaccinationsView() {
onRetry={() => void refetch()}
/>
) : records.length > 0 ? (
-
+
) : (
}
title={t("vaccinations.empty.title")}
description={t("vaccinations.empty.description")}
action={
-