diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 34d19567b..dabf15d07 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -932,7 +932,7 @@ paths: type: number const: 2 leaves: - maxItems: 92 + maxItems: 93 type: array items: type: string @@ -1037,7 +1037,7 @@ paths: type: number const: 2 leaves: - maxItems: 92 + maxItems: 93 type: array items: type: string @@ -16157,7 +16157,7 @@ components: type: number const: 2 leaves: - maxItems: 92 + maxItems: 93 type: array items: type: string diff --git a/e2e/setup/vaccination-fixture.ts b/e2e/setup/vaccination-fixture.ts new file mode 100644 index 000000000..0078f2685 --- /dev/null +++ b/e2e/setup/vaccination-fixture.ts @@ -0,0 +1,89 @@ +/** + * The immunization-log spec's account hygiene. + * + * `vaccinations.spec.ts` asserts GROUPED verdicts — a combination dose appears + * in each of its three component groups, one antigen holds at most one minted + * booster reminder, a logged next dose moves the reminder's due date forward. + * Every one of those is a count over the seeded e2e user's live rows, so the + * spec has to own the rows it counts against: a dose left by an earlier run, a + * Playwright retry, or a second project sharing the account would leave a + * stale group on the page and a second reminder in the antigen bucket, and the + * grouped assertions would read the wrong count. + * + * The reset is delete-then-assert-clean, keyed by the seeded user's id. Links + * go first for their foreign key; the minted booster reminders go last and are + * scoped to the ones this feature writes — `vaccination_antigen IS NOT NULL` — + * so an unrelated Vorsorge reminder another spec relies on is never touched. + */ +import pg from "pg"; + +import { E2E_USER } from "./global-setup"; + +async function getUserId(pool: pg.Pool): Promise { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_USER.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[vaccination-fixture] e2e user not seeded — global-setup must run first", + ); + } + return id; +} + +/** + * Remove the seeded e2e user's vaccination rows, their document links, and the + * booster reminders they minted. Safe to call from every test's `beforeEach`. + */ +export async function resetVaccinations(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vaccination-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + // Links first — they FK into the record rows. + await pool.query( + "DELETE FROM vaccination_document_links WHERE user_id = $1", + [userId], + ); + await pool.query("DELETE FROM vaccination_records WHERE user_id = $1", [ + userId, + ]); + // Only the reminders this feature mints. A booster reminder is the only + // kind that carries an antigen; a checkup another spec created has none and + // stays. + await pool.query( + "DELETE FROM measurement_reminders WHERE user_id = $1 AND vaccination_antigen IS NOT NULL", + [userId], + ); + } finally { + await pool.end(); + } +} + +/** + * Put the seeded e2e user's `vaccinations` module back to its default-on state. + * + * The module-off test flips the toggle through the real settings UI, and that + * write persists on the shared account past the test that made it. Every other + * flow needs the surface reachable, so this clears the `vaccinations` key from + * the preferences blob — default-on is absence — leaving any other module the + * account carries (`nutrients`) untouched. Called from `beforeEach` so no test + * order can strand the surface off. + */ +export async function ensureVaccinationsModuleOn(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vaccination-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + await pool.query( + "UPDATE users SET module_preferences_json = module_preferences_json - 'vaccinations' WHERE id = $1 AND module_preferences_json ? 'vaccinations'", + [userId], + ); + } finally { + await pool.end(); + } +} diff --git a/e2e/vaccinations.spec.ts b/e2e/vaccinations.spec.ts new file mode 100644 index 000000000..42f8f5ef4 --- /dev/null +++ b/e2e/vaccinations.spec.ts @@ -0,0 +1,337 @@ +/** + * The immunization log, proven end to end. + * + * Five flows, one script each, each assertion addressed to a stable + * `data-slot` / `data-*` attribute and never to viewport text — the copy is + * i18n-driven and the same slots paint at a different size on the mobile + * project, so a text assertion would break for the wrong reason. + * + * 1. Transcribe — a catalogue pick plus a lot number saves and reads back in + * its antigen group with a resolved series label. + * 2. Combination — one Tdap renders a row in each of its three component + * groups, the render-only duplication the series design exists for. + * 3. Mint + satisfy — confirming the booster prompt writes an ordinary + * Vorsorge reminder that shows on `/checkups`; logging the next dose moves + * its due date forward through the real satisfy matcher. + * 4. Module off — turning the module off in settings drops the nav entry and + * the direct visit lands on the module-off redirect, not a crash. + * 5. a11y — axe over the list and the capture form, serious/critical only. + * + * The flows mutate the one seeded account, so — like `visits.spec.ts` — this + * file runs in a single project (see `playwright.config.ts`) and serial, and + * clears its own rows before each test so a grouped verdict counts only what + * the test itself wrote. + */ +import AxeBuilder from "@axe-core/playwright"; +import type { APIRequestContext, Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import { + ensureVaccinationsModuleOn, + resetVaccinations, +} from "./setup/vaccination-fixture"; + +test.beforeEach(async () => { + // Own the counts every grouped assertion reads, and undo whatever the + // module-off flow left behind. + await resetVaccinations(); + await ensureVaccinationsModuleOn(); +}); + +// Serial and single-account: the reset above would race a sibling worker, and +// two tests clearing the same rows would each undo the other's setup. +test.describe.configure({ mode: "serial" }); + +/** One reminder as the list route publishes it. */ +interface ReminderDTO { + id: string; + origin: string; + nextDueAt: string | null; +} + +async function listReminders( + request: APIRequestContext, +): Promise { + const res = await request.get("/api/measurement-reminders"); + expect(res.status()).toBe(200); + return ((await res.json()) as { data: ReminderDTO[] }).data; +} + +async function listVaccinationIds( + request: APIRequestContext, +): Promise { + const res = await request.get("/api/vaccinations"); + expect(res.status()).toBe(200); + const body = (await res.json()) as { + data: { vaccinations: { id: string }[] }; + }; + return body.data.vaccinations.map((row) => row.id); +} + +/** + * Drive the capture sheet: open it, pick a catalogue antigen by its slug, set + * the date and an optional lot, and save. `booster` says what to do with the + * mint prompt a booster-bearing antigen raises after the save. + */ +async function captureDose( + page: Page, + opts: { + catalogSlug: string; + /** `YYYY-MM-DD`. Defaults to the form's own today. */ + date?: string; + lot?: string; + booster: "confirm" | "decline" | "none"; + }, +): Promise { + // The header Add renders whether or not the list is empty, so clicking it + // works in both states. click() auto-waits for the button to be actionable, + // which avoids racing an instant visibility read against the client render + // (an empty-state-only branch times out once a record already exists). + await page.locator('[data-slot="vaccination-add"]').first().click(); + + await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible(); + + if (opts.date) { + await page.getByTestId("vaccination-occurred-at").fill(opts.date); + } + + await page.locator('[data-slot="vaccination-catalog-trigger"]').click(); + await page + .locator('[data-slot="vaccination-catalog-search"]') + .fill(opts.catalogSlug); + await page + .locator( + `[data-slot="vaccination-catalog-option"][data-catalog-slug="${opts.catalogSlug}"]`, + ) + .click(); + + if (opts.lot) { + await page.locator("#vaccination-lot").fill(opts.lot); + } + + await page.locator('[data-slot="vaccination-save"]').click(); + + if (opts.booster === "confirm") { + await page.locator('[data-slot="vaccination-booster-confirm"]').click(); + } else if (opts.booster === "decline") { + await page.locator('[data-slot="vaccination-booster-decline"]').click(); + } + // The prompt (when raised) and the save button both leave the tree once the + // flow settles; waiting on the save button's detachment keeps the next step + // off a stale sheet. + await expect(page.locator('[data-slot="vaccination-save"]')).toBeHidden(); +} + +test.describe("vaccinations", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("a dose transcribed with a catalogue pick and a lot reads back in its antigen group", async ({ + page, + request, + }) => { + await page.goto("/vaccinations"); + + // Polio carries no booster interval, so the capture completes without a + // mint prompt — this flow is about the transcription, not the reminder. + await captureDose(page, { + catalogSlug: "polio", + lot: "LOT-E2E-001", + booster: "none", + }); + + const [id] = await listVaccinationIds(request); + expect(id, "the dose was written").toBeTruthy(); + + const group = page.locator( + '[data-slot="vaccination-group"][data-antigen="polio"]', + ); + await expect(group).toBeVisible({ timeout: 15_000 }); + + const row = group.locator(`[data-vaccination-id="${id}"]`); + await expect(row).toBeVisible(); + // The series label is resolved server-side and rendered as its own slot. + await expect(row.locator('[data-slot="vaccination-series"]')).toBeVisible(); + // The lot rides the row's meta line under its own slot. + await expect(row.locator('[data-slot="vaccination-lot"]')).toBeVisible(); + }); + + test("a combination dose appears in each of its three component groups", async ({ + page, + request, + }) => { + await page.goto("/vaccinations"); + + // Tdap's primary component (tetanus) carries a booster interval, so the + // mint prompt is raised — decline it; this flow is about the grouping. + await captureDose(page, { catalogSlug: "tdap", booster: "decline" }); + + const [id] = await listVaccinationIds(request); + expect(id, "the Tdap dose was written").toBeTruthy(); + + // One record, three appearances — once under each component antigen, each + // its own group, each carrying the same record id. + for (const antigen of ["tetanus", "diphtheria", "pertussis"] as const) { + const row = page.locator( + `[data-slot="vaccination-group"][data-antigen="${antigen}"] [data-vaccination-id="${id}"]`, + ); + await expect(row, `Tdap appears under ${antigen}`).toBeVisible({ + timeout: 15_000, + }); + } + }); + + test("confirming the booster prompt mints a reminder that a logged next dose re-anchors", async ({ + page, + request, + }) => { + const before = new Set((await listReminders(request)).map((r) => r.id)); + + await page.goto("/vaccinations"); + + // The first dose sits years back, so the minted reminder's first due date + // (dose + interval) is anchored in the past relative to a dose logged now — + // which is what lets the re-anchor below prove a forward move. + await captureDose(page, { + catalogSlug: "tetanus", + date: "2016-03-15", + booster: "confirm", + }); + + // Confirming the prompt closes the sheet before the mint's write has + // necessarily propagated to the list route, so poll for the new reminder + // rather than reading once against an in-flight mutation. + await expect + .poll( + async () => + (await listReminders(request)).filter((r) => !before.has(r.id)) + .length, + { timeout: 15_000 }, + ) + .toBe(1); + + const minted = (await listReminders(request)).find( + (r) => !before.has(r.id), + ); + expect(minted, "the mint wrote exactly one new reminder").toBeDefined(); + expect(minted!.origin).toBe("VORSORGE"); + expect(minted!.nextDueAt).toBeTruthy(); + const dueBefore = new Date(minted!.nextDueAt!).getTime(); + + // It presents on /checkups like every other Vorsorge reminder — the file-a + // -visit affordance only paints on a rendered reminder card, so it standing + // in for "a reminder card is here" is a stable per-card slot. + await page.goto("/checkups"); + await expect( + page.locator('[data-slot="vorsorge-file-visit"]').first(), + ).toBeVisible({ timeout: 15_000 }); + + // Log the next dose today. The Phase 1 satisfy matcher re-anchors the + // minted reminder on this dose during the create, so its due date moves + // forward off the years-old anchor. + await page.goto("/vaccinations"); + await captureDose(page, { catalogSlug: "tetanus", booster: "decline" }); + + // The satisfy re-anchor runs inside the second dose's create transaction; + // poll the reminder's due date forward rather than reading once, so a scan + // that beats the commit does not read the pre-anchor value. + await expect + .poll( + async () => { + const row = (await listReminders(request)).find( + (r) => r.id === minted!.id, + ); + return row?.nextDueAt ? new Date(row.nextDueAt).getTime() : null; + }, + { timeout: 15_000 }, + ) + .toBeGreaterThan(dueBefore); + }); + + test("turning the module off drops the nav entry and the direct visit redirects", async ({ + page, + }) => { + await page.goto("/vaccinations"); + // The surface is present to begin with. + await expect( + page.locator('[data-tour-id="vaccinations-hero"]'), + ).toBeVisible({ timeout: 15_000 }); + + // Flip the real settings switch off and wait for the resolved module map to + // repaint the shell. + await page.goto("/settings/modules"); + const toggle = page.locator("#module-toggle-vaccinations"); + await expect(toggle).toBeVisible({ timeout: 15_000 }); + await expect(toggle).toBeChecked(); + await toggle.click(); + await expect(toggle).not.toBeChecked(); + + // The nav entry is gone. + await expect(page.locator('[data-tour-id="nav-vaccinations"]')).toHaveCount( + 0, + ); + + // A direct visit no longer renders the surface: the page redirects home, + // so the hero never paints and the dashboard's own landmark does. + await page.goto("/vaccinations"); + await expect(page).toHaveURL(/\/$|\/dashboard/, { timeout: 15_000 }); + await expect( + page.locator('[data-tour-id="vaccinations-hero"]'), + ).toHaveCount(0); + }); + + test("the list and the capture form carry no serious accessibility violations", async ({ + page, + request, + }) => { + // Seed one dose so the list scans a populated surface rather than the empty + // state, then scan both the list and the open capture form. + const seeded = await request.post("/api/vaccinations", { + data: { occurredAt: "2019-05-01T00:00:00.000Z", antigenSlug: "polio" }, + }); + expect(seeded.status()).toBe(201); + + await page.goto("/vaccinations"); + await expect( + page.locator('[data-slot="vaccination-group"][data-antigen="polio"]'), + ).toBeVisible({ timeout: 15_000 }); + await expectNoSeriousAxe(page); + + await page.locator('[data-slot="vaccination-add"]').first().click(); + await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible(); + await expectNoSeriousAxe(page); + }); +}); + +/** + * Wait until every running CSS animation/transition on the page has settled. + * + * The capture sheet fades in, and axe reads computed colours: a scan that races + * the fade measures a half-opacity blend of the muted foreground over the card + * and reports phantom `color-contrast` failures that clear the instant the + * animation lands. Settling first makes the scan read the real, final surface — + * it never relaxes what axe asserts. + */ +async function waitForAnimationsSettled(page: Page): Promise { + await page.evaluate(async () => { + const anims = document + .getAnimations() + .map((a) => a.finished.catch(() => undefined)); + await Promise.all(anims); + }); +} + +/** Fail only on serious/critical WCAG violations, the suite's a11y floor. */ +async function expectNoSeriousAxe(page: Page): Promise { + await waitForAnimationsSettled(page); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + const blocking = results.violations.filter( + (v) => v.impact === "serious" || v.impact === "critical", + ); + expect( + blocking.map((v) => v.id), + "serious/critical accessibility violations", + ).toEqual([]); +} diff --git a/messages/de.json b/messages/de.json index a925e5193..d86231fae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -290,7 +290,12 @@ "visitsColKind": "Art", "visitsColReason": "Anlass", "visitsColOutcome": "Ergebnis", - "visitsColConditions": "Erkrankungen" + "visitsColConditions": "Erkrankungen", + "immunizationsTitle": "Impfungen", + "immunizationsColDate": "Datum", + "immunizationsColVaccine": "Impfstoff", + "immunizationsColDose": "Dosis", + "immunizationsColLot": "Charge" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -354,6 +359,7 @@ "vorsorge": "Vorsorge", "illness": "Krankheit", "documents": "Dokumente", + "vaccinations": "Impfungen", "mentalWellbeing": "Seelisches Wohlbefinden" }, "auth": { @@ -8507,6 +8513,138 @@ "description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung." } }, + "vaccinations": { + "title": "Impfungen", + "subtitle": "Dein Impfpass, Dosis für Dosis.", + "listLoadError": "Impfungen konnten nicht geladen werden.", + "series": { + "booster": "Auffrischung", + "ofTotal": "Dosis {position} von {total}", + "doseN": "{position}. Dosis" + }, + "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", + "linkDocuments": "Dokument verknüpfen", + "linkNothingToOffer": "Noch keine Dokumente vorhanden." + }, + "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.", + "info": { + "primarySeriesOne": "Grundimmunisierung: {count} Dosis", + "primarySeriesFew": "Grundimmunisierung: {count} Dosen", + "primarySeriesOther": "Grundimmunisierung: {count} Dosen", + "yearly": "Jährliche Impfung", + "boosterYearsOne": "Auffrischung üblicherweise alle {count} Jahr", + "boosterYearsFew": "Auffrischung üblicherweise alle {count} Jahre", + "boosterYearsOther": "Auffrischung üblicherweise alle {count} Jahre", + "boosterMonthsOne": "Auffrischung üblicherweise alle {count} Monat", + "boosterMonthsFew": "Auffrischung üblicherweise alle {count} Monate", + "boosterMonthsOther": "Auffrischung üblicherweise alle {count} Monate", + "standard60": "Standardimpfung ab 60", + "sourceLabel": "Quelle: {source}", + "affordanceLabel": "Impfschema-Info" + }, + "booster": { + "title": "Auffrischung planen?", + "body": "Aus dieser Impfung lässt sich eine Erinnerung für die nächste Auffrischung anlegen. Du kannst Intervall und Bezeichnung ändern oder ablehnen.", + "interval": "Intervall", + "label": "Bezeichnung", + "labelSuffix": "Auffrischung", + "decline": "Nicht jetzt", + "confirm": "Erinnerung anlegen", + "planned": "Auffrischung geplant", + "failed": "Erinnerung konnte nicht angelegt werden.", + "everyYearsOne": "alle {count} Jahr", + "everyYearsFew": "alle {count} Jahre", + "everyYearsOther": "alle {count} Jahre", + "everyMonthsOne": "alle {count} Monat", + "everyMonthsFew": "alle {count} Monate", + "everyMonthsOther": "alle {count} Monate" + }, + "suggestion": { + "single": "Zu dieser Impfung hinzufügen?", + "choose": "Zu welcher Impfung gehört der Scan?", + "linked": "Mit Impfung verknüpft", + "failed": "Verknüpfung fehlgeschlagen.", + "unnamed": "Impfung" + }, + "empty": { + "title": "Noch keine Impfung erfasst", + "description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen." + }, + "catalog": { + "tetanus": "Tetanus (Wundstarrkrampf)", + "diphtheria": "Diphtherie", + "pertussis": "Keuchhusten (Pertussis)", + "polio": "Polio (Kinderlähmung)", + "measles": "Masern", + "mumps": "Mumps", + "rubella": "Röteln", + "varicella": "Windpocken (Varizellen)", + "hib": "Hib (Haemophilus influenzae b)", + "hepatitis-a": "Hepatitis A", + "hepatitis-b": "Hepatitis B", + "influenza": "Grippe (Influenza)", + "pneumococcal": "Pneumokokken", + "herpes-zoster": "Gürtelrose (Herpes zoster)", + "rsv": "RSV", + "covid-19": "COVID-19", + "fsme": "FSME (Frühsommer-Meningoenzephalitis)", + "hpv": "HPV (Humane Papillomviren)", + "meningococcal-acwy": "Meningokokken ACWY", + "meningococcal-b": "Meningokokken B", + "rotavirus": "Rotaviren", + "typhoid": "Typhus", + "rabies": "Tollwut", + "yellow-fever": "Gelbfieber", + "japanese-encephalitis": "Japanische Enzephalitis", + "cholera": "Cholera", + "td": "Td (Tetanus, Diphtherie)", + "tdap": "Tdap (Tetanus, Diphtherie, Pertussis)", + "tdap-ipv": "Tdap-IPV (Vierfachimpfung)", + "dtap-ipv": "DTaP-IPV (Vierfachimpfung, Kindesalter)", + "hexavalent": "Sechsfachimpfung (DTaP-IPV-Hib-HepB)", + "mmr": "MMR (Masern, Mumps, Röteln)", + "mmrv": "MMRV (Masern, Mumps, Röteln, Varizellen)", + "hepa-hepb": "Hepatitis A und B", + "hepa-typhoid": "Hepatitis A und Typhus" + } + }, "illness": { "title": "Krankheitstagebuch", "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.", @@ -9702,7 +9840,8 @@ "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.", "scopeCount": "{count} Einträge aus {groups} Bereichen", "scopeSensitive": "inkl. {names}", - "leafVisits": "Arztbesuche" + "leafVisits": "Arztbesuche", + "leafImmunizations": "Impfungen" }, "recordSharing": { "banner": { @@ -10002,7 +10141,8 @@ "documentUpdate": "{name} hat ein Dokument geändert", "documentDiscard": "{name} hat ein Dokument verworfen", "medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig", - "medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen" + "medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen", + "vaccinationBoosterPlanned": "{name} hat eine Auffrischung geplant" }, "section": { "measurements": { diff --git a/messages/en.json b/messages/en.json index 12f18a262..2b9733388 100644 --- a/messages/en.json +++ b/messages/en.json @@ -290,7 +290,12 @@ "visitsColKind": "Kind", "visitsColReason": "Reason", "visitsColOutcome": "Outcome", - "visitsColConditions": "Conditions" + "visitsColConditions": "Conditions", + "immunizationsTitle": "Immunizations", + "immunizationsColDate": "Date", + "immunizationsColVaccine": "Vaccine", + "immunizationsColDose": "Dose", + "immunizationsColLot": "Lot" }, "errorBoundary": { "title": "Something went wrong", @@ -354,6 +359,7 @@ "vorsorge": "Checkups", "illness": "Illness", "documents": "Documents", + "vaccinations": "Vaccinations", "mentalWellbeing": "Mental wellbeing" }, "auth": { @@ -8507,6 +8513,138 @@ "description": "The vaccination record, structured: dose, series, next booster." } }, + "vaccinations": { + "title": "Vaccinations", + "subtitle": "Your immunization record, dose by dose.", + "listLoadError": "Vaccinations could not be loaded.", + "series": { + "booster": "Booster", + "ofTotal": "Dose {position} of {total}", + "doseN": "Dose {position}" + }, + "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", + "linkDocuments": "Link a document", + "linkNothingToOffer": "No documents yet." + }, + "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.", + "info": { + "primarySeriesOne": "Primary series: {count} dose", + "primarySeriesFew": "Primary series: {count} doses", + "primarySeriesOther": "Primary series: {count} doses", + "yearly": "Yearly vaccination", + "boosterYearsOne": "Booster typically every {count} year", + "boosterYearsFew": "Booster typically every {count} years", + "boosterYearsOther": "Booster typically every {count} years", + "boosterMonthsOne": "Booster typically every {count} month", + "boosterMonthsFew": "Booster typically every {count} months", + "boosterMonthsOther": "Booster typically every {count} months", + "standard60": "Standard vaccination from age 60", + "sourceLabel": "Source: {source}", + "affordanceLabel": "Schedule info" + }, + "booster": { + "title": "Plan a booster?", + "body": "You can set a reminder for the next booster from this dose. Change the interval or the label, or decline.", + "interval": "Interval", + "label": "Label", + "labelSuffix": "booster", + "decline": "Not now", + "confirm": "Set reminder", + "planned": "Booster planned", + "failed": "The reminder could not be created.", + "everyYearsOne": "every {count} year", + "everyYearsFew": "every {count} years", + "everyYearsOther": "every {count} years", + "everyMonthsOne": "every {count} month", + "everyMonthsFew": "every {count} months", + "everyMonthsOther": "every {count} months" + }, + "suggestion": { + "single": "File against this vaccination?", + "choose": "Which vaccination does this scan belong to?", + "linked": "Linked to vaccination", + "failed": "Linking failed.", + "unnamed": "Vaccination" + }, + "empty": { + "title": "No vaccination logged yet", + "description": "Transcribe your vaccination card line by line — a date and the vaccine are enough." + }, + "catalog": { + "tetanus": "Tetanus", + "diphtheria": "Diphtheria", + "pertussis": "Whooping cough (pertussis)", + "polio": "Polio", + "measles": "Measles", + "mumps": "Mumps", + "rubella": "Rubella", + "varicella": "Chickenpox (varicella)", + "hib": "Hib (Haemophilus influenzae b)", + "hepatitis-a": "Hepatitis A", + "hepatitis-b": "Hepatitis B", + "influenza": "Influenza (flu)", + "pneumococcal": "Pneumococcus", + "herpes-zoster": "Shingles (herpes zoster)", + "rsv": "RSV", + "covid-19": "COVID-19", + "fsme": "Tick-borne encephalitis (TBE)", + "hpv": "HPV (human papillomavirus)", + "meningococcal-acwy": "Meningococcus ACWY", + "meningococcal-b": "Meningococcus B", + "rotavirus": "Rotavirus", + "typhoid": "Typhoid", + "rabies": "Rabies", + "yellow-fever": "Yellow fever", + "japanese-encephalitis": "Japanese encephalitis", + "cholera": "Cholera", + "td": "Td (tetanus, diphtheria)", + "tdap": "Tdap (tetanus, diphtheria, pertussis)", + "tdap-ipv": "Tdap-IPV (four-in-one)", + "dtap-ipv": "DTaP-IPV (childhood four-in-one)", + "hexavalent": "Six-in-one (DTaP-IPV-Hib-HepB)", + "mmr": "MMR (measles, mumps, rubella)", + "mmrv": "MMRV (measles, mumps, rubella, varicella)", + "hepa-hepb": "Hepatitis A and B", + "hepa-typhoid": "Hepatitis A and typhoid" + } + }, "illness": { "title": "Illness journal", "subtitle": "A retrospective log of your illness and condition episodes.", @@ -9702,7 +9840,8 @@ "scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.", "scopeCount": "{count} entries from {groups} areas", "scopeSensitive": "incl. {names}", - "leafVisits": "Visits" + "leafVisits": "Visits", + "leafImmunizations": "Immunizations" }, "recordSharing": { "banner": { @@ -10002,7 +10141,8 @@ "documentUpdate": "{name} changed a document", "documentDiscard": "{name} discarded a document", "medicationIntakeImportFinished": "The dose import {name} started has finished", - "medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded" + "medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded", + "vaccinationBoosterPlanned": "{name} planned a booster" }, "section": { "measurements": { diff --git a/messages/es.json b/messages/es.json index 3ec016e1c..9f3c62e0b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -290,7 +290,12 @@ "visitsColKind": "Tipo", "visitsColReason": "Motivo", "visitsColOutcome": "Resultado", - "visitsColConditions": "Enfermedades" + "visitsColConditions": "Enfermedades", + "immunizationsTitle": "Vacunas", + "immunizationsColDate": "Fecha", + "immunizationsColVaccine": "Vacuna", + "immunizationsColDose": "Dosis", + "immunizationsColLot": "Lote" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -354,6 +359,7 @@ "vorsorge": "Revisiones", "illness": "Enfermedad", "documents": "Documentos", + "vaccinations": "Vacunas", "mentalWellbeing": "Bienestar mental" }, "auth": { @@ -8507,6 +8513,138 @@ "description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo." } }, + "vaccinations": { + "title": "Vacunas", + "subtitle": "Tu registro de vacunación, dosis a dosis.", + "listLoadError": "No se pudieron cargar las vacunas.", + "series": { + "booster": "Refuerzo", + "ofTotal": "Dosis {position} de {total}", + "doseN": "Dosis {position}" + }, + "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", + "linkDocuments": "Vincular un documento", + "linkNothingToOffer": "Aún no hay documentos." + }, + "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.", + "info": { + "primarySeriesOne": "Serie primaria: {count} dosis", + "primarySeriesFew": "Serie primaria: {count} dosis", + "primarySeriesOther": "Serie primaria: {count} dosis", + "yearly": "Vacunación anual", + "boosterYearsOne": "Refuerzo normalmente cada {count} año", + "boosterYearsFew": "Refuerzo normalmente cada {count} años", + "boosterYearsOther": "Refuerzo normalmente cada {count} años", + "boosterMonthsOne": "Refuerzo normalmente cada {count} mes", + "boosterMonthsFew": "Refuerzo normalmente cada {count} meses", + "boosterMonthsOther": "Refuerzo normalmente cada {count} meses", + "standard60": "Vacunación estándar a partir de los 60", + "sourceLabel": "Fuente: {source}", + "affordanceLabel": "Información del esquema" + }, + "booster": { + "title": "¿Planificar un refuerzo?", + "body": "Puedes crear un recordatorio para el próximo refuerzo a partir de esta dosis. Cambia el intervalo o la etiqueta, o recházalo.", + "interval": "Intervalo", + "label": "Etiqueta", + "labelSuffix": "refuerzo", + "decline": "Ahora no", + "confirm": "Crear recordatorio", + "planned": "Refuerzo planificado", + "failed": "No se pudo crear el recordatorio.", + "everyYearsOne": "cada {count} año", + "everyYearsFew": "cada {count} años", + "everyYearsOther": "cada {count} años", + "everyMonthsOne": "cada {count} mes", + "everyMonthsFew": "cada {count} meses", + "everyMonthsOther": "cada {count} meses" + }, + "suggestion": { + "single": "¿Asociar a esta vacuna?", + "choose": "¿A qué vacuna pertenece este escaneo?", + "linked": "Vinculado a la vacuna", + "failed": "No se pudo vincular.", + "unnamed": "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." + }, + "catalog": { + "tetanus": "Tétanos", + "diphtheria": "Difteria", + "pertussis": "Tos ferina (pertussis)", + "polio": "Poliomielitis", + "measles": "Sarampión", + "mumps": "Paperas", + "rubella": "Rubéola", + "varicella": "Varicela", + "hib": "Hib (Haemophilus influenzae b)", + "hepatitis-a": "Hepatitis A", + "hepatitis-b": "Hepatitis B", + "influenza": "Gripe (influenza)", + "pneumococcal": "Neumococo", + "herpes-zoster": "Herpes zóster (culebrilla)", + "rsv": "VRS", + "covid-19": "COVID-19", + "fsme": "Encefalitis por garrapatas (TBE)", + "hpv": "VPH (virus del papiloma humano)", + "meningococcal-acwy": "Meningococo ACWY", + "meningococcal-b": "Meningococo B", + "rotavirus": "Rotavirus", + "typhoid": "Fiebre tifoidea", + "rabies": "Rabia", + "yellow-fever": "Fiebre amarilla", + "japanese-encephalitis": "Encefalitis japonesa", + "cholera": "Cólera", + "td": "Td (tétanos, difteria)", + "tdap": "Tdap (tétanos, difteria, tos ferina)", + "tdap-ipv": "Tdap-IPV (tetravalente)", + "dtap-ipv": "DTaP-IPV (tetravalente infantil)", + "hexavalent": "Hexavalente (DTaP-IPV-Hib-HepB)", + "mmr": "Triple vírica (sarampión, paperas, rubéola)", + "mmrv": "Tetravírica (sarampión, paperas, rubéola, varicela)", + "hepa-hepb": "Hepatitis A y B", + "hepa-typhoid": "Hepatitis A y fiebre tifoidea" + } + }, "illness": { "title": "Diario de enfermedad", "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.", @@ -9702,7 +9840,8 @@ "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.", "scopeCount": "{count} entradas de {groups} áreas", "scopeSensitive": "incl. {names}", - "leafVisits": "Visitas médicas" + "leafVisits": "Visitas médicas", + "leafImmunizations": "Vacunas" }, "recordSharing": { "banner": { @@ -10002,7 +10141,8 @@ "documentUpdate": "{name} cambió un documento", "documentDiscard": "{name} descartó un documento", "medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado", - "medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}" + "medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}", + "vaccinationBoosterPlanned": "{name} planificó un refuerzo" }, "section": { "measurements": { diff --git a/messages/fr.json b/messages/fr.json index 0a07a45b3..4d0a640d9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -290,7 +290,12 @@ "visitsColKind": "Type", "visitsColReason": "Motif", "visitsColOutcome": "Résultat", - "visitsColConditions": "Maladies" + "visitsColConditions": "Maladies", + "immunizationsTitle": "Vaccinations", + "immunizationsColDate": "Date", + "immunizationsColVaccine": "Vaccin", + "immunizationsColDose": "Dose", + "immunizationsColLot": "Lot" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -354,6 +359,7 @@ "vorsorge": "Examens", "illness": "Maladie", "documents": "Documents", + "vaccinations": "Vaccinations", "mentalWellbeing": "Bien-être mental" }, "auth": { @@ -8507,6 +8513,138 @@ "description": "Le carnet de vaccination structuré : dose, série, prochain rappel." } }, + "vaccinations": { + "title": "Vaccinations", + "subtitle": "Votre carnet de vaccination, dose par dose.", + "listLoadError": "Impossible de charger les vaccinations.", + "series": { + "booster": "Rappel", + "ofTotal": "Dose {position} sur {total}", + "doseN": "Dose {position}" + }, + "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", + "linkDocuments": "Associer un document", + "linkNothingToOffer": "Aucun document pour l'instant." + }, + "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.", + "info": { + "primarySeriesOne": "Primovaccination : {count} dose", + "primarySeriesFew": "Primovaccination : {count} doses", + "primarySeriesOther": "Primovaccination : {count} doses", + "yearly": "Vaccination annuelle", + "boosterYearsOne": "Rappel généralement tous les {count} an", + "boosterYearsFew": "Rappel généralement tous les {count} ans", + "boosterYearsOther": "Rappel généralement tous les {count} ans", + "boosterMonthsOne": "Rappel généralement tous les {count} mois", + "boosterMonthsFew": "Rappel généralement tous les {count} mois", + "boosterMonthsOther": "Rappel généralement tous les {count} mois", + "standard60": "Vaccination standard à partir de 60 ans", + "sourceLabel": "Source : {source}", + "affordanceLabel": "Infos sur le schéma" + }, + "booster": { + "title": "Planifier un rappel ?", + "body": "Vous pouvez créer un rappel pour le prochain rappel à partir de cette dose. Modifiez l'intervalle ou le libellé, ou refusez.", + "interval": "Intervalle", + "label": "Libellé", + "labelSuffix": "rappel", + "decline": "Pas maintenant", + "confirm": "Créer le rappel", + "planned": "Rappel planifié", + "failed": "Le rappel n'a pas pu être créé.", + "everyYearsOne": "tous les {count} an", + "everyYearsFew": "tous les {count} ans", + "everyYearsOther": "tous les {count} ans", + "everyMonthsOne": "tous les {count} mois", + "everyMonthsFew": "tous les {count} mois", + "everyMonthsOther": "tous les {count} mois" + }, + "suggestion": { + "single": "Associer à cette vaccination ?", + "choose": "À quelle vaccination appartient ce scan ?", + "linked": "Associé à la vaccination", + "failed": "L'association a échoué.", + "unnamed": "Vaccination" + }, + "empty": { + "title": "Aucune vaccination enregistrée", + "description": "Recopiez votre carnet de vaccination ligne par ligne : une date et le vaccin suffisent." + }, + "catalog": { + "tetanus": "Tétanos", + "diphtheria": "Diphtérie", + "pertussis": "Coqueluche", + "polio": "Poliomyélite", + "measles": "Rougeole", + "mumps": "Oreillons", + "rubella": "Rubéole", + "varicella": "Varicelle", + "hib": "Hib (Haemophilus influenzae b)", + "hepatitis-a": "Hépatite A", + "hepatitis-b": "Hépatite B", + "influenza": "Grippe", + "pneumococcal": "Pneumocoque", + "herpes-zoster": "Zona (herpès zoster)", + "rsv": "VRS", + "covid-19": "COVID-19", + "fsme": "Encéphalite à tiques (TBE)", + "hpv": "HPV (papillomavirus humain)", + "meningococcal-acwy": "Méningocoque ACWY", + "meningococcal-b": "Méningocoque B", + "rotavirus": "Rotavirus", + "typhoid": "Typhoïde", + "rabies": "Rage", + "yellow-fever": "Fièvre jaune", + "japanese-encephalitis": "Encéphalite japonaise", + "cholera": "Choléra", + "td": "dT (tétanos, diphtérie)", + "tdap": "dTca (tétanos, diphtérie, coqueluche)", + "tdap-ipv": "dTca-VPI (quadruple)", + "dtap-ipv": "DTCa-VPI (quadruple, enfance)", + "hexavalent": "Hexavalent (DTCa-VPI-Hib-VHB)", + "mmr": "ROR (rougeole, oreillons, rubéole)", + "mmrv": "RORV (rougeole, oreillons, rubéole, varicelle)", + "hepa-hepb": "Hépatite A et B", + "hepa-typhoid": "Hépatite A et typhoïde" + } + }, "illness": { "title": "Journal de maladie", "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.", @@ -9702,7 +9840,8 @@ "scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.", "scopeCount": "{count} éléments issus de {groups} domaines", "scopeSensitive": "dont {names}", - "leafVisits": "Consultations" + "leafVisits": "Consultations", + "leafImmunizations": "Vaccinations" }, "recordSharing": { "banner": { @@ -10002,7 +10141,8 @@ "documentUpdate": "{name} a modifié un document", "documentDiscard": "{name} a écarté un document", "medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé", - "medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}" + "medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}", + "vaccinationBoosterPlanned": "{name} a planifié un rappel" }, "section": { "measurements": { diff --git a/messages/it.json b/messages/it.json index a820bcf1e..1168f6731 100644 --- a/messages/it.json +++ b/messages/it.json @@ -290,7 +290,12 @@ "visitsColKind": "Tipo", "visitsColReason": "Motivo", "visitsColOutcome": "Esito", - "visitsColConditions": "Malattie" + "visitsColConditions": "Malattie", + "immunizationsTitle": "Vaccinazioni", + "immunizationsColDate": "Data", + "immunizationsColVaccine": "Vaccino", + "immunizationsColDose": "Dose", + "immunizationsColLot": "Lotto" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -354,6 +359,7 @@ "vorsorge": "Controlli", "illness": "Malattia", "documents": "Documenti", + "vaccinations": "Vaccinazioni", "mentalWellbeing": "Benessere mentale" }, "auth": { @@ -8507,6 +8513,138 @@ "description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo." } }, + "vaccinations": { + "title": "Vaccinazioni", + "subtitle": "Il tuo libretto vaccinale, dose per dose.", + "listLoadError": "Impossibile caricare le vaccinazioni.", + "series": { + "booster": "Richiamo", + "ofTotal": "Dose {position} di {total}", + "doseN": "Dose {position}" + }, + "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", + "linkDocuments": "Collega un documento", + "linkNothingToOffer": "Nessun documento ancora." + }, + "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.", + "info": { + "primarySeriesOne": "Ciclo primario: {count} dose", + "primarySeriesFew": "Ciclo primario: {count} dosi", + "primarySeriesOther": "Ciclo primario: {count} dosi", + "yearly": "Vaccinazione annuale", + "boosterYearsOne": "Richiamo di norma ogni {count} anno", + "boosterYearsFew": "Richiamo di norma ogni {count} anni", + "boosterYearsOther": "Richiamo di norma ogni {count} anni", + "boosterMonthsOne": "Richiamo di norma ogni {count} mese", + "boosterMonthsFew": "Richiamo di norma ogni {count} mesi", + "boosterMonthsOther": "Richiamo di norma ogni {count} mesi", + "standard60": "Vaccinazione standard dai 60 anni", + "sourceLabel": "Fonte: {source}", + "affordanceLabel": "Informazioni sullo schema" + }, + "booster": { + "title": "Pianificare un richiamo?", + "body": "Da questa dose puoi creare un promemoria per il prossimo richiamo. Cambia l'intervallo o l'etichetta, oppure rifiuta.", + "interval": "Intervallo", + "label": "Etichetta", + "labelSuffix": "richiamo", + "decline": "Non ora", + "confirm": "Crea promemoria", + "planned": "Richiamo pianificato", + "failed": "Impossibile creare il promemoria.", + "everyYearsOne": "ogni {count} anno", + "everyYearsFew": "ogni {count} anni", + "everyYearsOther": "ogni {count} anni", + "everyMonthsOne": "ogni {count} mese", + "everyMonthsFew": "ogni {count} mesi", + "everyMonthsOther": "ogni {count} mesi" + }, + "suggestion": { + "single": "Collegare a questa vaccinazione?", + "choose": "A quale vaccinazione appartiene questa scansione?", + "linked": "Collegato alla vaccinazione", + "failed": "Collegamento non riuscito.", + "unnamed": "Vaccinazione" + }, + "empty": { + "title": "Nessuna vaccinazione registrata", + "description": "Trascrivi il tuo libretto vaccinale riga per riga: bastano una data e il vaccino." + }, + "catalog": { + "tetanus": "Tetano", + "diphtheria": "Difterite", + "pertussis": "Pertosse", + "polio": "Poliomielite", + "measles": "Morbillo", + "mumps": "Parotite", + "rubella": "Rosolia", + "varicella": "Varicella", + "hib": "Hib (Haemophilus influenzae b)", + "hepatitis-a": "Epatite A", + "hepatitis-b": "Epatite B", + "influenza": "Influenza", + "pneumococcal": "Pneumococco", + "herpes-zoster": "Herpes zoster (fuoco di Sant'Antonio)", + "rsv": "RSV", + "covid-19": "COVID-19", + "fsme": "Encefalite da zecche (TBE)", + "hpv": "HPV (papillomavirus umano)", + "meningococcal-acwy": "Meningococco ACWY", + "meningococcal-b": "Meningococco B", + "rotavirus": "Rotavirus", + "typhoid": "Febbre tifoide", + "rabies": "Rabbia", + "yellow-fever": "Febbre gialla", + "japanese-encephalitis": "Encefalite giapponese", + "cholera": "Colera", + "td": "Td (tetano, difterite)", + "tdap": "Tdap (tetano, difterite, pertosse)", + "tdap-ipv": "Tdap-IPV (tetravalente)", + "dtap-ipv": "DTPa-IPV (tetravalente pediatrico)", + "hexavalent": "Esavalente (DTPa-IPV-Hib-HBV)", + "mmr": "MPR (morbillo, parotite, rosolia)", + "mmrv": "MPRV (morbillo, parotite, rosolia, varicella)", + "hepa-hepb": "Epatite A e B", + "hepa-typhoid": "Epatite A e febbre tifoide" + } + }, "illness": { "title": "Diario delle malattie", "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.", @@ -9702,7 +9840,8 @@ "scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.", "scopeCount": "{count} voci da {groups} aree", "scopeSensitive": "incl. {names}", - "leafVisits": "Visite mediche" + "leafVisits": "Visite mediche", + "leafImmunizations": "Vaccinazioni" }, "recordSharing": { "banner": { @@ -10002,7 +10141,8 @@ "documentUpdate": "{name} ha modificato un documento", "documentDiscard": "{name} ha scartato un documento", "medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata", - "medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}" + "medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}", + "vaccinationBoosterPlanned": "{name} ha pianificato un richiamo" }, "section": { "measurements": { diff --git a/messages/pl.json b/messages/pl.json index 724882a79..31d997419 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -290,7 +290,12 @@ "visitsColKind": "Rodzaj", "visitsColReason": "Powód", "visitsColOutcome": "Wynik", - "visitsColConditions": "Choroby" + "visitsColConditions": "Choroby", + "immunizationsTitle": "Szczepienia", + "immunizationsColDate": "Data", + "immunizationsColVaccine": "Szczepionka", + "immunizationsColDose": "Dawka", + "immunizationsColLot": "Seria" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -354,6 +359,7 @@ "vorsorge": "Badania", "illness": "Choroba", "documents": "Dokumenty", + "vaccinations": "Szczepienia", "mentalWellbeing": "Dobrostan psychiczny" }, "auth": { @@ -8507,6 +8513,138 @@ "description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie." } }, + "vaccinations": { + "title": "Szczepienia", + "subtitle": "Twoja karta szczepień, dawka po dawce.", + "listLoadError": "Nie udało się załadować szczepień.", + "series": { + "booster": "Dawka przypominająca", + "ofTotal": "Dawka {position} z {total}", + "doseN": "Dawka {position}" + }, + "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", + "linkDocuments": "Powiąż dokument", + "linkNothingToOffer": "Brak dokumentów." + }, + "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.", + "info": { + "primarySeriesOne": "Seria podstawowa: {count} dawka", + "primarySeriesFew": "Seria podstawowa: {count} dawki", + "primarySeriesOther": "Seria podstawowa: {count} dawek", + "yearly": "Szczepienie coroczne", + "boosterYearsOne": "Dawka przypominająca zwykle co {count} rok", + "boosterYearsFew": "Dawka przypominająca zwykle co {count} lata", + "boosterYearsOther": "Dawka przypominająca zwykle co {count} lat", + "boosterMonthsOne": "Dawka przypominająca zwykle co {count} miesiąc", + "boosterMonthsFew": "Dawka przypominająca zwykle co {count} miesiące", + "boosterMonthsOther": "Dawka przypominająca zwykle co {count} miesięcy", + "standard60": "Szczepienie standardowe od 60. roku życia", + "sourceLabel": "Źródło: {source}", + "affordanceLabel": "Informacje o schemacie" + }, + "booster": { + "title": "Zaplanować dawkę przypominającą?", + "body": "Z tego szczepienia możesz utworzyć przypomnienie o następnej dawce przypominającej. Zmień odstęp lub etykietę albo odrzuć.", + "interval": "Odstęp", + "label": "Etykieta", + "labelSuffix": "przypomnienie", + "decline": "Nie teraz", + "confirm": "Utwórz przypomnienie", + "planned": "Zaplanowano dawkę przypominającą", + "failed": "Nie udało się utworzyć przypomnienia.", + "everyYearsOne": "co {count} rok", + "everyYearsFew": "co {count} lata", + "everyYearsOther": "co {count} lat", + "everyMonthsOne": "co {count} miesiąc", + "everyMonthsFew": "co {count} miesiące", + "everyMonthsOther": "co {count} miesięcy" + }, + "suggestion": { + "single": "Powiązać z tym szczepieniem?", + "choose": "Do którego szczepienia należy ten skan?", + "linked": "Powiązano ze szczepieniem", + "failed": "Powiązanie nie powiodło się.", + "unnamed": "Szczepienie" + }, + "empty": { + "title": "Nie zarejestrowano jeszcze żadnego szczepienia", + "description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka." + }, + "catalog": { + "tetanus": "Tężec", + "diphtheria": "Błonica", + "pertussis": "Krztusiec", + "polio": "Polio (choroba Heinego-Medina)", + "measles": "Odra", + "mumps": "Świnka", + "rubella": "Różyczka", + "varicella": "Ospa wietrzna", + "hib": "Hib (Haemophilus influenzae b)", + "hepatitis-a": "WZW typu A", + "hepatitis-b": "WZW typu B", + "influenza": "Grypa", + "pneumococcal": "Pneumokoki", + "herpes-zoster": "Półpasiec (herpes zoster)", + "rsv": "RSV", + "covid-19": "COVID-19", + "fsme": "KZM (kleszczowe zapalenie mózgu)", + "hpv": "HPV (wirus brodawczaka ludzkiego)", + "meningococcal-acwy": "Meningokoki ACWY", + "meningococcal-b": "Meningokoki B", + "rotavirus": "Rotawirusy", + "typhoid": "Dur brzuszny", + "rabies": "Wścieklizna", + "yellow-fever": "Żółta gorączka", + "japanese-encephalitis": "Japońskie zapalenie mózgu", + "cholera": "Cholera", + "td": "Td (tężec, błonica)", + "tdap": "Tdap (tężec, błonica, krztusiec)", + "tdap-ipv": "Tdap-IPV (poczwórna)", + "dtap-ipv": "DTaP-IPV (poczwórna dziecięca)", + "hexavalent": "Sześciowalentna (DTaP-IPV-Hib-WZWB)", + "mmr": "MMR (odra, świnka, różyczka)", + "mmrv": "MMRV (odra, świnka, różyczka, ospa wietrzna)", + "hepa-hepb": "WZW typu A i B", + "hepa-typhoid": "WZW typu A i dur brzuszny" + } + }, "illness": { "title": "Dziennik chorób", "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.", @@ -9702,7 +9840,8 @@ "scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.", "scopeCount": "{count} pozycji z {groups} obszarów", "scopeSensitive": "w tym {names}", - "leafVisits": "Wizyty lekarskie" + "leafVisits": "Wizyty lekarskie", + "leafImmunizations": "Szczepienia" }, "recordSharing": { "banner": { @@ -10002,7 +10141,8 @@ "documentUpdate": "{name} zmienił dokument", "documentDiscard": "{name} odrzucił dokument", "medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się", - "medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}" + "medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}", + "vaccinationBoosterPlanned": "{name} zaplanował dawkę przypominającą" }, "section": { "measurements": { diff --git a/playwright.config.ts b/playwright.config.ts index 421731c48..0fb132700 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -143,6 +143,14 @@ export default defineConfig({ // the single-candidate branch to the picker. It proves a flow through // stable data-slots, not a mobile layout, so it runs in one project. "visits.spec.ts", + // The immunization-log journey transcribes doses, mints a booster + // reminder and logs a next dose against the one shared account, then + // reads grouped counts back off it. Two projects mutating that account + // in parallel would leave a stale group on the page and a second row in + // an antigen bucket, flipping the grouped verdicts. It proves flows + // through stable data-slots, not a mobile layout, so it runs in one + // project. + "vaccinations.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", ], diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 347983310..a9c42a979 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -400,6 +400,16 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "tombstoned-no-restore", confirm: ["AlertDialog"], }, + { + file: "components/vaccinations/use-vaccinations.ts", + destroys: + "one logged dose with its lot, site and the document links it collected — the booster reminder it once satisfied is deliberately not rewound", + triggers: ["components/vaccinations/vaccination-sheet.tsx"], + // The route soft-deletes and a restore route exists, but no surface offers + // the undo yet, so from a person's side the dose is gone. + recovery: "tombstoned-no-restore", + confirm: ["AlertDialog"], + }, // ── Cycle ─────────────────────────────────────────────────────────────── { diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index d9a137b7b..57227ecca 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(92); + expect(ALL_LEAF_IDS.length).toBe(93); expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); - expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15); + expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(16); }); it("places every leaf in exactly one group", () => { diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 150b5a624..03abb0fbb 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -75,6 +75,13 @@ function isMaskPlaceholder(words: string[]): boolean { * reason. Prune an entry only when the value genuinely diverges in a locale. */ const LEGIT_IDENTICAL = new Set([ + // Vaccine catalogue: clinical proper nouns whose accepted written form is the + // same across these locales. "Hib" carries its Latin binomial verbatim, and + // "Hepatitis A" / "Hepatitis B" read identically in Spanish. The catalogue + // ships no trade names, so these are disease names, not brands. + "vaccinations.catalog.hib", + "vaccinations.catalog.hepatitis-a", + "vaccinations.catalog.hepatitis-b", // The AM/PM marker label — the affix abbreviations themselves are the same // 12-hour-clock notation across the shipped Latin-script locales. "common.period", diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx index 65cf82ca5..8610e31de 100644 --- a/src/__tests__/shared-record-navigation.test.tsx +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -47,9 +47,15 @@ describe("shared-record navigation", () => { expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([ "/profile", "/checkups", + "/vaccinations", + ]); + expect(navigation.destinationHrefs).toEqual([ + "/profile", + "/checkups", + "/vaccinations", ]); - expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]); expect(navigation.allowsPath("/profile")).toBe(true); + expect(navigation.allowsPath("/vaccinations")).toBe(true); expect(navigation.allowsPath("/settings/anamnesis")).toBe(false); }); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 6452d98b6..b9d3f9897 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -951,6 +951,14 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "One dose of the record, fetch-then-guard against the resolved user. The pages it resolves are read through the link service, which narrows both ends to the same resolved id, and their names are withheld from a grant that does not reach the vault — the filename of a scanned page is itself the sensitive part.", }, + "app/api/vaccinations/[id]/booster/route.ts": { + domain: "profile", + why: "Arming the booster reminder a logged dose suggests. The antigen is read from the dose's own catalogue entry server-side, never from the body, so a delegate cannot key a reminder onto an antigen the dose does not contain, and the reminder is minted under the RECORD — it is the owner's booster plan and the owner's phone it rings, which is correct even when a helper transcribes the Impfpass.", + }, + "app/api/vaccinations/suggest/route.ts": { + domain: "profile", + why: "Which of the record's doses a scanned page dated around a given day belongs to. A read over the same rows the immunization list serves, reduced to a verdict — the caller learns nothing it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.", + }, "app/api/practitioners/route.ts": { domain: "profile", why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.", @@ -1401,6 +1409,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Adding a doctor or practice to the record's address book. Nothing is unique across accounts here by design, so a delegate adding a practice the owner already has produces a second row rather than a collision with somebody else's namespace.", "app/api/vaccinations/route.ts": "Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.", + "app/api/vaccinations/[id]/booster/route.ts": + "Confirming the booster reminder a dose suggests. The reminder is minted under the RECORD and keyed on the antigen the server reads from the dose's catalogue entry, never from the body, so a delegate cannot point it at an antigen the dose does not contain; a second confirmation re-anchors the one reminder rather than minting another.", "app/api/medications/[id]/side-effects/route.ts": "Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.", "app/api/medications/route.ts": @@ -1902,7 +1912,7 @@ const ACTOR_ROUTES: Record = { * record list, one create on the write literal, and the edit/delete pair plus * the restore on the manage literal. 215 -> 221. */ -const FROZEN_ENTRY_COUNT = 221; +const FROZEN_ENTRY_COUNT = 224; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2663,7 +2673,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(74); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index a21657ddb..97250c15b 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(74); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index cf730b806..f7e72fbdf 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -303,6 +303,12 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/vaccinations/[id]/route.ts", "src/app/api/vaccinations/[id]/restore/route.ts", "src/app/api/vaccinations/[id]/links/route.ts", + // The booster mint and the upload suggestion are the same posture: a restore + // or an import that arms a booster, or a document review that files a scan, + // must keep working with the surface hidden, so the data routes stay exempt + // while the nav entry, the picker and the report leaf hide. + "src/app/api/vaccinations/[id]/booster/route.ts", + "src/app/api/vaccinations/suggest/route.ts", ]; const MODULE_GATE_NEEDLE = "requireModuleEnabled("; diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index c442523cf..3e7b5185b 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({ illnessEpisode: { findMany: vi.fn() }, encounter: { findMany: vi.fn() }, allergy: { findMany: vi.fn() }, + vaccinationRecord: { findMany: vi.fn() }, familyHistoryEntry: { findMany: vi.fn() }, userHealthProfile: { findUnique: vi.fn() }, healthProfileFactRevision: { findMany: vi.fn() }, @@ -168,6 +169,7 @@ beforeEach(() => { vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never); vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( null as never, diff --git a/src/app/api/vaccinations/[id]/booster/route.ts b/src/app/api/vaccinations/[id]/booster/route.ts new file mode 100644 index 000000000..da406db47 --- /dev/null +++ b/src/app/api/vaccinations/[id]/booster/route.ts @@ -0,0 +1,111 @@ +/** + * `POST /api/vaccinations/{id}/booster` — mint (or re-anchor) the booster + * reminder a logged dose suggests. + * + * Rung 2: the reminder only exists because the person confirmed it, prefilled + * from the catalogue's interval and fully editable before this call. The minted + * row is an ordinary `origin: VORSORGE` reminder that carries the dose's + * primary antigen as a match key; it lists on `/checkups` and rings through the + * same engine as every other checkup, with no new code. + * + * WRITE, classified `profile`: filing the owner's Impfpass and arming the + * owner's booster plan are the same act, and both run on the owner's reminders + * even when a delegate is transcribing. The antigen is read from the dose's + * catalogue entry server-side, never from the body — a client cannot key a + * reminder onto an antigen the dose does not contain. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { vaccinationBoosterSchema } from "@/lib/validations/vaccinations"; +import { mintOrReanchorBooster } from "@/lib/vaccinations/booster-mint"; +import { resolveOwnerTimezone } from "@/lib/vaccinations/service"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("write", "profile"); + const { id } = await params; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = vaccinationBoosterSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.booster-invalid", + }); + } + + const timezone = await resolveOwnerTimezone(user.id); + const result = await prisma.$transaction((tx) => + mintOrReanchorBooster( + tx, + user.id, + { vaccinationId: id, ...parsed.data }, + timezone, + ), + ); + + if (result.outcome === "unknown-record") { + return apiError("Vaccination not found", 404, { + errorCode: "vaccination.not-found", + }); + } + if (result.outcome === "no-antigen") { + // A free-text-only dose has no antigen to key a booster on. The prompt is + // never offered for one; this is the defence if a request arrives anyway. + return apiError("This dose has no catalogue antigen to remind on", 422, { + errorCode: "vaccination.booster-no-antigen", + }); + } + + const reminder = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: result.reminderId }, + }); + + await auditLog("vaccination.booster.planned", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + vaccinationId: id, + reminderId: result.reminderId, + minted: result.outcome === "minted", + }, + }); + + annotate({ + action: { + name: "vaccination.booster.planned", + entity_type: "measurement-reminder", + entity_id: result.reminderId, + }, + meta: { + antigen_slug: result.antigen, + minted: result.outcome === "minted", + }, + }); + + return apiSuccess( + { + reminder: toMeasurementReminderDto(reminder), + minted: result.outcome === "minted", + }, + result.outcome === "minted" ? 201 : 200, + ); + }, +); diff --git a/src/app/api/vaccinations/suggest/route.ts b/src/app/api/vaccinations/suggest/route.ts new file mode 100644 index 000000000..fd6288465 --- /dev/null +++ b/src/app/api/vaccinations/suggest/route.ts @@ -0,0 +1,56 @@ +/** + * `GET /api/vaccinations/suggest?anchor=` — which dose does a document + * dated `anchor` most plausibly belong to? + * + * The document upload review asks this when a scan is classified `VACCINATION`. + * The rule — the ±7-day window (shared with the visit moment), and the + * one-pre-selects / two-offer-a-picker verdict — lives in + * `src/lib/vaccinations/document-suggestion.ts` and is answered here so the + * browser never re-derives it. + * + * A read of the record's own doses, so it declares the same `profile` domain + * the immunization list does. `userId` is narrowed from auth; the anchor is the + * only input and it is a date, not an id. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { vaccinationSuggestQuerySchema } from "@/lib/validations/vaccinations"; +import { suggestVaccinationForDate } from "@/lib/vaccinations/document-suggestion"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "profile"); + + const params = new URL(request.url).searchParams; + const parsed = vaccinationSuggestQuerySchema.safeParse({ + anchor: params.get("anchor") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.invalid", + }); + } + + const result = await suggestVaccinationForDate(prisma, { + userId: user.id, + anchor: new Date(parsed.data.anchor), + }); + + annotate({ + action: { name: "vaccination.dose.suggest", entity_type: "vaccination" }, + meta: { + verdict: result.kind, + candidates: + result.kind === "many" + ? result.vaccinations.length + : result.kind === "one" + ? 1 + : 0, + }, + }); + + return apiSuccess(result); +}); diff --git a/src/app/vaccinations/page.tsx b/src/app/vaccinations/page.tsx new file mode 100644 index 000000000..dd7637c37 --- /dev/null +++ b/src/app/vaccinations/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; + +/** + * v1.38.0 — the immunization log entry. + * + * Gated on the resolved `modules.vaccinations` flag from `GET /api/auth/me`. + * The module is default-on, so `=== false` is the only state that hides the + * surface — an account that never touched the toggle keeps it. An + * unauthenticated visitor is bounced to login; an authenticated account that + * turned the module off is bounced home (the nav entry is already gone for + * them, so this only catches a direct URL hit). The `/api/vaccinations*` + * routes stay reachable regardless — the module gate governs the surface, not + * the row store — so this is a UX redirect, never the security boundary. + */ +export default function VaccinationsPage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); + const router = useRouter(); + + // Default-on: hidden only when the resolved map says `false` explicitly. A + // shared-record session always sees it — the delegate's own module map does + // not govern the owner's record. + const enabled = inSharedRecord || user?.modules?.vaccinations !== false; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ; + } + + return ; +} diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index b2459a76f..2d947998b 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -80,6 +80,7 @@ import { } from "@/lib/validations/inbound-documents"; import { DocumentAiSection } from "./document-ai-section"; import { DocumentEncounterSuggestion } from "./document-encounter-suggestion"; +import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion"; import { DocumentSummaryBlock } from "./document-summary-block"; import type { DocumentAiTarget } from "./document-ai-transport"; import { DocumentShareSheet } from "./document-share-sheet"; @@ -1045,6 +1046,16 @@ export function DocumentDetailSheet({ onChange={setSuggestedEncounter} /> + {/* A vaccination scan offers to file against the dose it records, + through the same ±7-day rule the visit suggestion uses. */} + {doc.kind === "VACCINATION" ? ( + + ) : null} + {/* "Belongs to visit" — read-only here on purpose. The filing happens at the moment the document arrives, or from the visit's own sheet; a second editor for the same link would diff --git a/src/components/encounters/practitioner-combobox.tsx b/src/components/encounters/practitioner-combobox.tsx index 453f0de31..c28dded64 100644 --- a/src/components/encounters/practitioner-combobox.tsx +++ b/src/components/encounters/practitioner-combobox.tsx @@ -67,6 +67,11 @@ export function PractitionerCombobox({ variant="outline" role="combobox" aria-expanded={open} + // `role="combobox"` is not a name-from-content role: the visible + // placeholder span does not give the trigger an accessible name, + // so name it from the field's own label. Applies wherever this + // combobox is embedded (the visit form and the immunization form). + aria-label={t("encounters.form.practitioner")} disabled={disabled} data-slot="encounter-practitioner-trigger" className="min-h-11 min-w-0 flex-1 justify-between font-normal" diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 4ab0b709a..edf3d032e 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -12,6 +12,7 @@ import { Pill, Settings, Stethoscope, + Syringe, Thermometer, Trophy, Waves, @@ -193,6 +194,20 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-illness", requiresModule: "illness", }, + // v1.38.0 — the immunization log sits in the clinical spine beside Illness. + // Born-gated: `requiresModule: "vaccinations"` reads the resolved module map, + // so the entry is absent until the account keeps the module on (default-on — + // it drops only when a user turns it off). SURFACE-gated: the `/api/vaccinations*` + // data routes stay reachable so a restore / import keeps working and + // re-enabling finds every dose intact. + { + href: "/vaccinations", + sharedRecord: true, + tKey: "nav.vaccinations", + icon: Syringe, + tourId: "nav-vaccinations", + requiresModule: "vaccinations", + }, // v1.18.0 — Workouts and Recovery both left the left-nav: each already // surfaces as an Insights tab-strip pill (`/insights/workouts` gated on // a workout row, `/insights/recovery` always present), so neither is a diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index 414be182d..83535c8b4 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -193,7 +193,7 @@ describe(" — the first run", () => { expect(groupChip(html, "labs")).toBe("0/1"); expect(groupChip(html, "medications")).toBe("0/4"); // ALLERGIES, ILLNESS_EPISODES and now VISITS. - expect(groupChip(html, "history")).toBe("0/3"); + expect(groupChip(html, "history")).toBe("0/4"); expect(groupChip(html, "cardio")).toBe("0/14"); expect(groupChip(html, "activity")).toBe("0/8"); expect(groupChip(html, "sleepRecovery")).toBe("0/16"); diff --git a/src/components/vaccinations/__tests__/catalog-info.test.tsx b/src/components/vaccinations/__tests__/catalog-info.test.tsx new file mode 100644 index 000000000..0296dfbf8 --- /dev/null +++ b/src/components/vaccinations/__tests__/catalog-info.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +import { CatalogInfo, catalogInfoAvailable } from "../catalog-info"; + +/** + * Rung 1 renders the catalogue's own numbers, cited, and nothing personal. + * The templates compose from `typicalSeriesDoses` / `boosterIntervalMonths` / + * `category`, so each null combination simply drops its line. + */ +function render(slug: string | null): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("CatalogInfo rung-1 text", () => { + it("renders the primary series and a year-interval booster with the source", () => { + // tetanus: 3 doses, 120-month (10-year) booster, STIKO source. + const html = render("tetanus"); + expect(html).toContain("Primary series: 3 doses"); + expect(html).toContain("Booster typically every 10 years"); + expect(html).toContain("Source: STIKO Epid Bull 4/2026"); + }); + + it("renders a yearly vaccine as yearly, not as an interval", () => { + // influenza: no primary series, 12-month interval → yearly. + const html = render("influenza"); + expect(html).toContain("Yearly vaccination"); + expect(html).not.toContain("every 1 year"); + // No primary-series line when the count is null. + expect(html).not.toContain("Primary series"); + }); + + it("renders nothing for a dead slug", () => { + expect(render("no-such-antigen")).toBe(""); + expect(catalogInfoAvailable("no-such-antigen")).toBe(false); + }); + + it("marks a standard60 entry available", () => { + // pneumococcal: 1 dose, no interval, standard60. + expect(catalogInfoAvailable("pneumococcal")).toBe(true); + const html = render("pneumococcal"); + expect(html).toContain("Standard vaccination from age 60"); + }); +}); 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/__tests__/vaccination-list.test.tsx b/src/components/vaccinations/__tests__/vaccination-list.test.tsx new file mode 100644 index 000000000..031b499b7 --- /dev/null +++ b/src/components/vaccinations/__tests__/vaccination-list.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { VaccinationDTO } from "@/lib/vaccinations/dto"; +import type { SeriesPosition } from "@/lib/vaccinations/series"; + +import { VaccinationList } from "../vaccination-list"; + +/** + * The list answers "what have I had" and "where does each series stand": one + * group per component antigen, a combined dose rendered once in each of its + * component groups with that component's own resolved position, and a + * free-text or dead-slug record folded under its verbatim name. The numbers + * come from the DTO — this suite never asserts a re-derivation. + */ +function record( + over: Partial & { id: string; series: SeriesPosition[] }, +): VaccinationDTO { + return { + occurredAt: "2020-01-01T00:00:00.000Z", + antigenSlug: null, + vaccineName: null, + doseNumber: null, + seriesDoses: null, + lotNumber: null, + site: null, + catalogEntry: null, + practitioner: null, + encounter: null, + reminderId: null, + note: null, + createdAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2020-01-01T00:00:00.000Z", + ...over, + }; +} + +function render(records: VaccinationDTO[]): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("VaccinationList grouping", () => { + it("renders a combination dose in every component group", () => { + const tdap = record({ + id: "tdap-1", + antigenSlug: "tdap", + catalogEntry: { slug: "tdap", atc: "J07AJ52", category: "standard" }, + series: [ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "diphtheria", position: 1, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ], + }); + const html = render([tdap]); + + for (const antigen of ["tetanus", "diphtheria", "pertussis"]) { + expect(html).toContain(`data-antigen="${antigen}"`); + } + // Three appearances of the one record — one per component group. + const appearances = html.match(/data-vaccination-id="tdap-1"/g) ?? []; + expect(appearances).toHaveLength(3); + }); + + it("places a monovalent and a combo in the same antigen group with per-component positions", () => { + const tetanus = record({ + id: "tet-1", + antigenSlug: "tetanus", + catalogEntry: { slug: "tetanus", atc: "J07AM01", category: "standard" }, + occurredAt: "2010-05-05T00:00:00.000Z", + series: [{ antigen: "tetanus", position: 2, total: 3, booster: false }], + }); + const tdap = record({ + id: "tdap-2", + antigenSlug: "tdap", + catalogEntry: { slug: "tdap", atc: "J07AJ52", category: "standard" }, + series: [ + { antigen: "tetanus", position: 3, total: 3, booster: false }, + { antigen: "pertussis", position: 1, total: 3, booster: false }, + ], + }); + const html = render([tetanus, tdap]); + + // The tetanus group holds both doses; the pertussis group only the Tdap. + const tetanusSection = html.slice(html.indexOf('data-antigen="tetanus"')); + expect(tetanusSection).toContain('data-vaccination-id="tet-1"'); + expect(html).toContain('data-antigen="pertussis"'); + // Dose 3 of 3 renders as resolved text, never recomputed. + expect(html).toContain("Dose 3 of 3"); + }); + + it("renders a booster past the series end as a booster, not a position", () => { + const html = render([ + record({ + id: "boost-1", + antigenSlug: "tetanus", + catalogEntry: { slug: "tetanus", atc: "J07AM01", category: "standard" }, + series: [{ antigen: "tetanus", position: 4, total: 3, booster: true }], + }), + ]); + expect(html).toContain("Booster"); + expect(html).not.toContain("Dose 4 of 3"); + }); + + it("folds a free-text and a dead-slug record under their verbatim name", () => { + const free = record({ + id: "free-1", + vaccineName: "Some old vaccine", + series: [], + }); + const deadSlug = record({ + id: "dead-1", + antigenSlug: "retired-antigen", + vaccineName: "Retired brand", + catalogEntry: null, + series: [], + }); + const html = render([free, deadSlug]); + + expect(html).toContain('data-antigen="free"'); + expect(html).toContain("Some old vaccine"); + expect(html).toContain("Retired brand"); + // A free-text row shows no series sentence — nothing is guessed. + const freeSection = html.slice( + html.indexOf('data-vaccination-id="free-1"'), + ); + expect(freeSection.slice(0, 400)).not.toContain( + 'data-slot="vaccination-series"', + ); + }); +}); diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx new file mode 100644 index 000000000..9fa6caa4e --- /dev/null +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -0,0 +1,169 @@ +"use client"; + +/** + * Rung 2, at the point of offer: after a dose whose catalogue entry carries a + * booster interval, a prefilled — never imposed — prompt to plan the next one. + * + * The interval and the label are prefilled from the catalogue and the person's + * own record, and both are editable before they confirm; declining is one tap + * and leaves no state behind. Nothing here computes what a person is due: the + * catalogue's typical interval seeds a reminder the person owns, and that is + * the whole of the "recommendation". Confirming writes an ordinary Vorsorge + * reminder that keys on the dose's antigen; the satisfy matcher usually + * re-anchored one already during the create, so the common path simply confirms + * the new due date. + * + * The prompt never blocks: the create has already completed and the sheet has + * already closed by the time this appears. + */ +import { useState } from "react"; +import { toast } from "sonner"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { FieldGroup } from "@/components/ui/field-group"; +import { Input } from "@/components/ui/input"; +import { NativeSelect } from "@/components/ui/native-select"; +import { useTranslations } from "@/lib/i18n/context"; +import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; +import { useBoosterMint, type Vaccination } from "./use-vaccinations"; + +/** + * Whether a freshly created dose should raise the offer at all. Only a + * catalogue entry that carries a booster interval does; a free-text row and a + * one-off vaccine never prompt. + */ +export function boosterOfferFor( + record: Vaccination, +): { intervalMonths: number; catalogName: string } | null { + const entry = resolveCatalogEntry(record.antigenSlug); + if (!entry || entry.boosterIntervalMonths === null) return null; + return { + intervalMonths: entry.boosterIntervalMonths, + catalogName: entry.slug, + }; +} + +const MONTH_OPTIONS = [6, 12, 24, 36, 60, 120] as const; + +export function BoosterMintPrompt({ + record, + onClose, +}: { + /** The just-created dose, or null when there is nothing to offer. */ + record: Vaccination | null; + onClose: () => void; +}) { + const { t, tCount } = useTranslations(); + const mint = useBoosterMint(); + const offer = record ? boosterOfferFor(record) : null; + + // The default label is the catalogue name plus the booster word, composed in + // the person's own locale — the server never resolves an i18n key. + const defaultLabel = + record && offer + ? `${t(`vaccinations.catalog.${record.antigenSlug}`)} ${t("vaccinations.booster.labelSuffix")}` + : ""; + const [label, setLabel] = useState(defaultLabel); + const [months, setMonths] = useState(offer?.intervalMonths ?? 12); + + // The prompt is only mounted when there is an offer; guard for the type. + if (!record || !offer) return null; + + const confirm = async () => { + try { + await mint.mutateAsync({ + id: record.id, + body: { intervalMonths: months, label: label.trim() || defaultLabel }, + }); + toastWrittenOutcome("success", t("vaccinations.booster.planned")); + } catch (err) { + toast.error( + err instanceof Error ? err.message : t("vaccinations.booster.failed"), + ); + } finally { + onClose(); + } + }; + + const intervalOptions = MONTH_OPTIONS.includes( + offer.intervalMonths as (typeof MONTH_OPTIONS)[number], + ) + ? MONTH_OPTIONS + : ([offer.intervalMonths, ...MONTH_OPTIONS] as readonly number[]); + + return ( + (!open ? onClose() : undefined)}> + + + {t("vaccinations.booster.title")} + + {t("vaccinations.booster.body")} + + + +
+ + setMonths(Number(event.target.value))} + > + {intervalOptions.map((value) => ( + + ))} + + + + + setLabel(event.target.value)} + /> + +
+ + + + {t("vaccinations.booster.decline")} + + { + // Keep the dialog until the write settles, then close it. + event.preventDefault(); + void confirm(); + }} + > + {t("vaccinations.booster.confirm")} + + +
+
+ ); +} diff --git a/src/components/vaccinations/catalog-info.tsx b/src/components/vaccinations/catalog-info.tsx new file mode 100644 index 000000000..49a434a63 --- /dev/null +++ b/src/components/vaccinations/catalog-info.tsx @@ -0,0 +1,99 @@ +"use client"; + +/** + * Rung 1: the catalogue's own knowledge, rendered with its citation. + * + * This is information reproduction, never a personal recommendation. The + * sentences are composed from the seed's `typicalSeriesDoses`, + * `boosterIntervalMonths` and `category` over a handful of i18n templates — + * not hand-written per entry — so six locales carry ~6 templates rather than + * ~35 prose blocks. Every sentence is population-level and impersonal by + * construction: it states what the cited schedule says, with no "you should" + * and no conditioning on age, sex or history. That conditioning would be rung + * 3, and rung 3 is a different venture. + * + * The per-entry `source` renders verbatim as the footnote — a citation is a + * proper noun, like a unit, and is not translated. + */ +import { useTranslations } from "@/lib/i18n/context"; +import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; + +type Translate = ReturnType["t"]; +type TranslateCount = ReturnType["tCount"]; + +/** The impersonal sentences that apply to this entry, in reading order. */ +function infoSentences( + slug: string | null, + t: Translate, + tCount: TranslateCount, +): { lines: string[]; source: string } | null { + const entry = resolveCatalogEntry(slug); + if (!entry) return null; + + const lines: string[] = []; + + if (entry.typicalSeriesDoses !== null) { + lines.push( + tCount("vaccinations.info.primarySeries", entry.typicalSeriesDoses, { + count: entry.typicalSeriesDoses, + }), + ); + } + + if (entry.boosterIntervalMonths !== null) { + const months = entry.boosterIntervalMonths; + if (months === 12) { + lines.push(t("vaccinations.info.yearly")); + } else if (months % 12 === 0) { + const years = months / 12; + lines.push( + tCount("vaccinations.info.boosterYears", years, { count: years }), + ); + } else { + lines.push( + tCount("vaccinations.info.boosterMonths", months, { count: months }), + ); + } + } + + if (entry.category === "standard60") { + lines.push(t("vaccinations.info.standard60")); + } + + if (lines.length === 0) return null; + return { lines, source: entry.source }; +} + +/** + * Whether {@link CatalogInfo} would render anything for this slug — so a + * caller can decide to mount an info affordance at all, rather than opening an + * empty popover for an entry the templates say nothing about. + */ +export function catalogInfoAvailable(slug: string | null): boolean { + const entry = resolveCatalogEntry(slug); + if (!entry) return false; + return ( + entry.typicalSeriesDoses !== null || + entry.boosterIntervalMonths !== null || + entry.category === "standard60" + ); +} + +export function CatalogInfo({ slug }: { slug: string | null }) { + const { t, tCount } = useTranslations(); + const info = infoSentences(slug, t, tCount); + if (!info) return null; + + return ( +
+
    + {info.lines.map((line, index) => ( +
  • {line}
  • + ))} +
+

+ {t("vaccinations.info.sourceLabel", { source: info.source })} +

+
+ ); +} diff --git a/src/components/vaccinations/catalog-picker.tsx b/src/components/vaccinations/catalog-picker.tsx new file mode 100644 index 000000000..8c2507f59 --- /dev/null +++ b/src/components/vaccinations/catalog-picker.tsx @@ -0,0 +1,166 @@ +"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"; +import { CatalogInfo } from "./catalog-info"; + +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} +
+ + {/* Rung 1: the catalogue's sourced sentences for the chosen entry. */} + {selected ? : null} +
+ ); +} diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts new file mode 100644 index 000000000..947a97536 --- /dev/null +++ b/src/components/vaccinations/use-vaccinations.ts @@ -0,0 +1,190 @@ +"use client"; + +/** + * v1.38.0 — immunization-log read hooks. + * + * Reads unwrap the envelope `data` (via `apiGet`) per the project rule, and + * every key is factory-routed through `queryKeys.vaccination*`. The list + * arrives with each dose's `series` already resolved per component antigen — + * this client renders text from those numbers and never re-derives "N von M". + * + * Writes and the booster mint land with the capture form; they invalidate + * `vaccinationDependentKeys`, which evicts the preventive-care root alongside + * the dose list because logging a dose re-anchors the booster it answers. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +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"; +import type { VaccinationSuggestionResult } from "@/lib/vaccinations/document-suggestion"; + +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. + * + * `antigenSlug` filters the server query to one antigen's history; the series + * numbers are still derived over the whole live set on the server, so a + * filtered view never reports the oldest visible dose as the first ever given. + */ +export function useVaccinations(antigenSlug?: string | null, enabled = true) { + return useQuery({ + queryKey: queryKeys.vaccinationList(antigenSlug ?? null), + enabled, + queryFn: () => + apiGet( + antigenSlug + ? `${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 }; +} + +/** + * Which dose a document dated `anchor` most plausibly belongs to. + * + * The verdict is server-resolved through the shared ±7-day window, so the + * browser never re-derives which dose a scan belongs to — and the "many" + * verdict carries no pre-selection, so a caller cannot pre-select one of two. + */ +export function useVaccinationSuggestion( + anchor: string | null, + enabled = true, +) { + return useQuery({ + queryKey: queryKeys.vaccinationSuggestion(anchor ?? ""), + enabled: enabled && Boolean(anchor), + queryFn: () => + apiGet( + `${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`, + ), + }); +} + +/** Link a document to a dose from the document side (the upload suggestion). */ +export function useLinkDocumentToVaccination() { + const qc = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.vaccinationLink(), + mutationFn: ({ + vaccinationId, + documentId, + }: { + vaccinationId: string; + documentId: string; + }) => + apiPost(`${BASE}/${vaccinationId}/links`, { + targetKind: "document", + targetIds: [documentId], + }), + onSuccess: () => invalidateKeys(qc, vaccinationDependentKeys), + }); +} + +/** What the booster confirm carries — the user's accepted-or-edited values. */ +export interface BoosterMintBody { + intervalMonths: number; + label: string; + notifyHour?: number; +} + +/** + * Mint (or re-anchor) the booster reminder a dose suggests. + * + * The response is an ordinary Vorsorge reminder; the write fans out through the + * dose dependent-keys and the reminder reads so the new checkup appears on + * `/checkups` the instant it is confirmed. + */ +export function useBoosterMint() { + const qc = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.vaccinationBooster(), + mutationFn: ({ id, body }: { id: string; body: BoosterMintBody }) => + apiPost<{ reminder: unknown; minted: boolean }>( + `${BASE}/${id}/booster`, + body, + ), + onSuccess: () => + Promise.all([ + invalidateKeys(qc, vaccinationDependentKeys), + invalidateReminderReads(qc), + ]), + }); +} diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx new file mode 100644 index 000000000..0246172a7 --- /dev/null +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -0,0 +1,123 @@ +"use client"; + +/** + * The from-the-record document link: attach the scanned page a dose was + * transcribed from. + * + * **The gate blanks the block, it does not post-filter it.** When the + * `inboundDocuments` module is off, this renders nothing — no heading, no empty + * list — because an empty picker for a switched-off module advertises a feature + * that is not there. The same shape the visit form's link pickers use. + * + * Nothing here can block a save: the list starts empty and stays valid empty, + * and linking is optional, capped and idempotent behind the link facade. + */ +import { useQuery } from "@tanstack/react-query"; +import { FolderOpen } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +const OPTION_LIMIT = 25; + +interface DocumentListPage { + documents: InboundDocumentDto[]; +} + +export function VaccinationDocumentPicker({ + enabled, + documentIds, + onChange, +}: { + /** The `inboundDocuments` module flag — false blanks the block entirely. */ + enabled: boolean; + documentIds: string[]; + onChange: (documentIds: string[]) => void; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + + const documents = useQuery({ + queryKey: queryKeys.inboundDocumentPicker("vaccination-form"), + enabled, + queryFn: () => + apiGet( + `/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`, + ), + }); + + if (!enabled) return null; + + const toggle = (id: string) => + onChange( + documentIds.includes(id) + ? documentIds.filter((entry) => entry !== id) + : [...documentIds, id], + ); + + const options = (documents.data?.documents ?? []).map((doc) => ({ + id: doc.id, + label: doc.title ?? doc.filename ?? doc.id, + date: doc.documentDate ?? doc.reportDate ?? doc.createdAt, + })); + + return ( +
+
+ + + {t("vaccinations.form.linkDocuments")} + + {documentIds.length > 0 ? ( + + {documentIds.length} + + ) : null} +
+ {documents.isPending ? ( + + ) : options.length === 0 ? ( +

+ {t("vaccinations.form.linkNothingToOffer")} +

+ ) : ( +
    + {options.map((option) => { + const on = documentIds.includes(option.id); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx new file mode 100644 index 000000000..ea07169aa --- /dev/null +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -0,0 +1,123 @@ +"use client"; + +/** + * The from-upload half of the document link: a scan classified `VACCINATION` + * offers to file itself against a dose recorded around the same date. + * + * The RULE is not here — it lives in `src/lib/vaccinations/document-suggestion.ts` + * and is resolved server-side, the same ±7-day window the visit moment uses, so + * the two cannot drift. What this owns is only how the three verdicts look: + * + * one candidate → pre-selected, visibly; + * two or more → a picker with NOTHING pre-selected; + * none → nothing at all. + * + * The middle branch is the point, and this file never picks one of a "many": + * that verdict carries no pre-selection. It never blocks the upload — linking is + * an offer, and the facade's link is idempotent, so a second tap is a no-op. + */ +import { useState } from "react"; +import { Check, Syringe } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + useLinkDocumentToVaccination, + useVaccinationSuggestion, +} from "./use-vaccinations"; +import type { VaccinationSuggestion } from "@/lib/vaccinations/document-suggestion"; + +export function VaccinationDocumentSuggestion({ + anchor, + documentId, + disabled, +}: { + /** `reportDate ?? documentDate`, date-only; null means nothing to ask. */ + anchor: string | null; + documentId: string; + disabled?: boolean; +}) { + const { t } = useTranslations(); + const format = useFormatters(); + // Lift the date-only anchor to noon UTC — the rule is a ±7-day window, so the + // time never changes the verdict, and noon keeps a viewer's local midnight + // from rounding the day either way. The bare date would 422 the endpoint. + const instant = anchor ? `${anchor}T12:00:00.000Z` : null; + const suggestion = useVaccinationSuggestion(instant, !disabled); + const link = useLinkDocumentToVaccination(); + const [linkedId, setLinkedId] = useState(null); + + if (disabled) return null; + const result = suggestion.data; + if (!result || result.kind === "none") return null; + + const candidates = + result.kind === "one" ? [result.vaccination] : result.vaccinations; + + const label = (candidate: VaccinationSuggestion) => { + const name = candidate.antigenSlug + ? t(`vaccinations.catalog.${candidate.antigenSlug}`) + : (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed")); + return `${name} · ${format.date(candidate.occurredAt)}`; + }; + + const onPick = (candidate: VaccinationSuggestion) => { + link.mutate( + { vaccinationId: candidate.id, documentId }, + { + onSuccess: () => { + setLinkedId(candidate.id); + toastWrittenOutcome("success", t("vaccinations.suggestion.linked")); + }, + onError: () => toast.error(t("vaccinations.suggestion.failed")), + }, + ); + }; + + return ( +
+

+ + {t( + result.kind === "one" + ? "vaccinations.suggestion.single" + : "vaccinations.suggestion.choose", + )} +

+
+ {candidates.map((candidate) => { + const chosen = linkedId === candidate.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx new file mode 100644 index 000000000..c9b5885d9 --- /dev/null +++ b/src/components/vaccinations/vaccination-form.tsx @@ -0,0 +1,313 @@ +"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 { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import type { Practitioner } from "@/hooks/use-practitioners"; +import type { Vaccination, VaccinationWriteBody } from "./use-vaccinations"; +import { CatalogPicker } from "./catalog-picker"; +import { VaccinationDocumentPicker } from "./vaccination-document-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 { user } = useAuth(); + 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" + /> + + +