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