From ff72917833c81f45dbdd0d67231c7abfc2f06ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 20:40:40 +0200 Subject: [PATCH] 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 --- messages/de.json | 37 +++ messages/en.json | 37 +++ messages/es.json | 37 +++ messages/fr.json | 37 +++ messages/it.json | 37 +++ messages/pl.json | 37 +++ .../__tests__/vaccination-form.test.ts | 74 +++++ .../vaccinations/catalog-picker.tsx | 143 +++++++++ .../vaccinations/use-vaccinations.ts | 79 ++++- .../vaccinations/vaccination-form.tsx | 296 ++++++++++++++++++ .../vaccinations/vaccination-list.tsx | 37 ++- .../vaccinations/vaccination-sheet.tsx | 171 ++++++++++ .../vaccinations/vaccinations-view.tsx | 42 ++- src/lib/query-keys/vaccinations.ts | 5 + 14 files changed, 1056 insertions(+), 13 deletions(-) create mode 100644 src/components/vaccinations/__tests__/vaccination-form.test.ts create mode 100644 src/components/vaccinations/catalog-picker.tsx create mode 100644 src/components/vaccinations/vaccination-form.tsx create mode 100644 src/components/vaccinations/vaccination-sheet.tsx 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 ( +
+ + patch({ occurredAt: value })} + max={todayLocal()} + required + data-testid="vaccination-occurred-at" + /> + + + + patch({ antigenSlug: slug })} + /> + + + + patch({ vaccineName: event.target.value })} + placeholder={t("vaccinations.form.freeTextPlaceholder")} + data-slot="vaccination-free-text" + /> + + + {seriesOpen ? ( +
+ + patch({ doseNumber: event.target.value })} + /> + + + patch({ seriesDoses: event.target.value })} + /> + +
+ ) : ( + + )} + + + patch({ lotNumber: event.target.value })} + /> + + + + patch({ site: event.target.value })} + > + + {VACCINATION_SITES.map((site) => ( + + ))} + + + + + patch({ practitioner: next })} + /> + + + patch({ encounterId: id })} + slot="vaccination-encounter-suggestion" + /> + + +