diff --git a/messages/de.json b/messages/de.json index c1908fddc..4e0812d27 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Eigenen Impfstoff entfernen?", "sectionDescription": "Selbst angelegte Impfstoffe, die der Katalog nicht kennt.", "catalogGroup": "Katalog" - } + }, + "deleted": "Impfung gelöscht", + "restoreFailed": "Impfung konnte nicht wiederhergestellt werden." }, "illness": { "title": "Krankheit", @@ -13492,7 +13494,9 @@ "defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.", "capped": "Die letzten zwei Jahre", "cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre." - } + }, + "deleted": "Termin gelöscht", + "restoreFailed": "Termin konnte nicht wiederhergestellt werden." }, "checkups": { "viewSwitchLabel": "Vorsorge, Termine, Eingriffe oder Körperstellen anzeigen", @@ -13525,7 +13529,9 @@ "deleteTitle": "Eintrag löschen?", "deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.", "deleteFailed": "Eintrag konnte nicht gelöscht werden.", - "noSpecialtyGroup": "Ohne Fachrichtung" + "noSpecialtyGroup": "Ohne Fachrichtung", + "deleted": "Eintrag gelöscht", + "restoreFailed": "Eintrag konnte nicht wiederhergestellt werden." }, "links": { "picker": { diff --git a/messages/en.json b/messages/en.json index ca52adbf4..aa3c7dcd8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Remove own vaccine?", "sectionDescription": "Vaccines you added yourself because the catalogue does not list them.", "catalogGroup": "Catalogue" - } + }, + "deleted": "Vaccination deleted", + "restoreFailed": "The vaccination could not be restored." }, "illness": { "title": "Illness", @@ -13492,7 +13494,9 @@ "defaultReason": "No earlier visit with the same practice, specialty or kind.", "capped": "The last two years", "cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most." - } + }, + "deleted": "Visit deleted", + "restoreFailed": "The visit could not be restored." }, "checkups": { "viewSwitchLabel": "Show checkups, visits, procedures or body sites", @@ -13525,7 +13529,9 @@ "deleteTitle": "Delete this entry?", "deleteDescription": "Your existing visits stay, and they keep this name.", "deleteFailed": "The entry could not be deleted.", - "noSpecialtyGroup": "No specialty" + "noSpecialtyGroup": "No specialty", + "deleted": "Entry deleted", + "restoreFailed": "The entry could not be restored." }, "links": { "picker": { diff --git a/messages/es.json b/messages/es.json index 17dde5112..edf57f570 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10980,7 +10980,9 @@ "deleteTitle": "¿Quitar la vacuna propia?", "sectionDescription": "Vacunas que añadiste tú porque el catálogo no las recoge.", "catalogGroup": "Catálogo" - } + }, + "deleted": "Vacuna eliminada", + "restoreFailed": "No se pudo restaurar la vacuna." }, "illness": { "title": "Enfermedad", @@ -13492,7 +13494,9 @@ "defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.", "capped": "Los últimos dos años", "cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años." - } + }, + "deleted": "Cita eliminada", + "restoreFailed": "No se pudo restaurar la cita." }, "checkups": { "viewSwitchLabel": "Mostrar revisiones, citas, procedimientos o zonas del cuerpo", @@ -13525,7 +13529,9 @@ "deleteTitle": "¿Eliminar esta entrada?", "deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.", "deleteFailed": "No se pudo eliminar la entrada.", - "noSpecialtyGroup": "Sin especialidad" + "noSpecialtyGroup": "Sin especialidad", + "deleted": "Entrada eliminada", + "restoreFailed": "No se pudo restaurar la entrada." }, "links": { "picker": { diff --git a/messages/fr.json b/messages/fr.json index c73ad4505..f44944f05 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Retirer ce vaccin personnel ?", "sectionDescription": "Les vaccins que vous avez ajoutés parce que le catalogue ne les connaît pas.", "catalogGroup": "Catalogue" - } + }, + "deleted": "Vaccination supprimée", + "restoreFailed": "La vaccination n'a pas pu être restaurée." }, "illness": { "title": "Maladie", @@ -13492,7 +13494,9 @@ "defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.", "capped": "Les deux dernières années", "cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans." - } + }, + "deleted": "Rendez-vous supprimé", + "restoreFailed": "Le rendez-vous n'a pas pu être restauré." }, "checkups": { "viewSwitchLabel": "Afficher les dépistages, les rendez-vous, les interventions ou les parties du corps", @@ -13525,7 +13529,9 @@ "deleteTitle": "Supprimer cette entrée ?", "deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.", "deleteFailed": "L'entrée n'a pas pu être supprimée.", - "noSpecialtyGroup": "Sans spécialité" + "noSpecialtyGroup": "Sans spécialité", + "deleted": "Entrée supprimée", + "restoreFailed": "L'entrée n'a pas pu être restaurée." }, "links": { "picker": { diff --git a/messages/it.json b/messages/it.json index 7883cfb66..ca17d5d5d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Rimuovere il vaccino personale?", "sectionDescription": "Vaccini aggiunti da te perché il catalogo non li elenca.", "catalogGroup": "Catalogo" - } + }, + "deleted": "Vaccinazione eliminata", + "restoreFailed": "Impossibile ripristinare la vaccinazione." }, "illness": { "title": "Malattia", @@ -13492,7 +13494,9 @@ "defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.", "capped": "Gli ultimi due anni", "cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni." - } + }, + "deleted": "Appuntamento eliminato", + "restoreFailed": "Non è stato possibile ripristinare l'appuntamento." }, "checkups": { "viewSwitchLabel": "Mostra controlli, appuntamenti, interventi o parti del corpo", @@ -13525,7 +13529,9 @@ "deleteTitle": "Eliminare questa voce?", "deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.", "deleteFailed": "Non è stato possibile eliminare la voce.", - "noSpecialtyGroup": "Senza specializzazione" + "noSpecialtyGroup": "Senza specializzazione", + "deleted": "Voce eliminata", + "restoreFailed": "Non è stato possibile ripristinare la voce." }, "links": { "picker": { diff --git a/messages/ko.json b/messages/ko.json index 23255f05e..3dd8f5bb9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10980,7 +10980,9 @@ "deleteTitle": "직접 추가한 백신을 삭제할까요?", "sectionDescription": "목록에 없어서 직접 추가한 백신이에요.", "catalogGroup": "목록" - } + }, + "deleted": "예방접종 기록을 삭제했어요", + "restoreFailed": "예방접종 기록을 복원하지 못했어요." }, "illness": { "title": "아픈 날", @@ -13492,7 +13494,9 @@ "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.", "capped": "최근 2년", "cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다." - } + }, + "deleted": "진료를 삭제했어요", + "restoreFailed": "진료를 복원하지 못했어요." }, "checkups": { "viewSwitchLabel": "건강검진, 진료, 시술 또는 신체 부위 보기", @@ -13525,7 +13529,9 @@ "deleteTitle": "이 항목을 삭제할까요?", "deleteDescription": "기존 진료 기록은 그대로 남고, 이 이름도 유지돼요.", "deleteFailed": "항목을 삭제하지 못했어요.", - "noSpecialtyGroup": "진료과 없음" + "noSpecialtyGroup": "진료과 없음", + "deleted": "항목을 삭제했어요", + "restoreFailed": "항목을 복원하지 못했어요." }, "links": { "picker": { diff --git a/messages/nl.json b/messages/nl.json index 6387a747e..cfe154d6e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Eigen vaccin verwijderen?", "sectionDescription": "Vaccins die je zelf hebt toegevoegd omdat ze niet in de catalogus staan.", "catalogGroup": "Catalogus" - } + }, + "deleted": "Vaccinatie verwijderd", + "restoreFailed": "De vaccinatie kon niet worden teruggezet." }, "illness": { "title": "Ziekte", @@ -13492,7 +13494,9 @@ "defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.", "capped": "De afgelopen twee jaar", "cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar." - } + }, + "deleted": "Bezoek verwijderd", + "restoreFailed": "Het bezoek kon niet worden teruggezet." }, "checkups": { "viewSwitchLabel": "Controles, bezoeken, ingrepen of lichaamsdelen tonen", @@ -13525,7 +13529,9 @@ "deleteTitle": "Deze vermelding verwijderen?", "deleteDescription": "Je bestaande bezoeken blijven bestaan en houden deze naam.", "deleteFailed": "De vermelding kon niet worden verwijderd.", - "noSpecialtyGroup": "Geen specialisme" + "noSpecialtyGroup": "Geen specialisme", + "deleted": "Vermelding verwijderd", + "restoreFailed": "De vermelding kon niet worden teruggezet." }, "links": { "picker": { diff --git a/messages/pl.json b/messages/pl.json index 505248df9..b426ef853 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Usunąć własną szczepionkę?", "sectionDescription": "Szczepionki dodane przez Ciebie, bo nie ma ich w katalogu.", "catalogGroup": "Katalog" - } + }, + "deleted": "Szczepienie usunięte", + "restoreFailed": "Nie udało się przywrócić szczepienia." }, "illness": { "title": "Choroba", @@ -13492,7 +13494,9 @@ "defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.", "capped": "Ostatnie dwa lata", "cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata." - } + }, + "deleted": "Wizyta usunięta", + "restoreFailed": "Nie udało się przywrócić wizyty." }, "checkups": { "viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty, zabiegi lub miejsca na ciele", @@ -13525,7 +13529,9 @@ "deleteTitle": "Usunąć ten wpis?", "deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.", "deleteFailed": "Nie udało się usunąć wpisu.", - "noSpecialtyGroup": "Bez specjalizacji" + "noSpecialtyGroup": "Bez specjalizacji", + "deleted": "Wpis usunięty", + "restoreFailed": "Nie udało się przywrócić wpisu." }, "links": { "picker": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 512eb208f..ce959a62c 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Remover vacina própria?", "sectionDescription": "Vacinas que você adicionou porque o catálogo não as lista.", "catalogGroup": "Catálogo" - } + }, + "deleted": "Vacina excluída", + "restoreFailed": "Não foi possível restaurar a vacina." }, "illness": { "title": "Doenças", @@ -13492,7 +13494,9 @@ "defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.", "capped": "Os últimos dois anos", "cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos." - } + }, + "deleted": "Consulta excluída", + "restoreFailed": "Não foi possível restaurar a consulta." }, "checkups": { "viewSwitchLabel": "Mostrar check-ups, consultas, procedimentos ou locais do corpo", @@ -13525,7 +13529,9 @@ "deleteTitle": "Excluir esta entrada?", "deleteDescription": "Suas consultas existentes permanecem e mantêm este nome.", "deleteFailed": "Não foi possível excluir a entrada.", - "noSpecialtyGroup": "Sem especialidade" + "noSpecialtyGroup": "Sem especialidade", + "deleted": "Entrada excluída", + "restoreFailed": "Não foi possível restaurar a entrada." }, "links": { "picker": { diff --git a/messages/ru.json b/messages/ru.json index 478b17ba2..d98b8a0b2 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Убрать свою вакцину?", "sectionDescription": "Вакцины, которые вы добавили сами, потому что их нет в каталоге.", "catalogGroup": "Каталог" - } + }, + "deleted": "Вакцинация удалена", + "restoreFailed": "Не удалось восстановить вакцинацию." }, "illness": { "title": "Заболевание", @@ -13492,7 +13494,9 @@ "defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.", "capped": "Последние два года", "cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет." - } + }, + "deleted": "Визит удалён", + "restoreFailed": "Визит не удалось восстановить." }, "checkups": { "viewSwitchLabel": "Показать осмотры, визиты, процедуры или области тела", @@ -13525,7 +13529,9 @@ "deleteTitle": "Удалить эту запись?", "deleteDescription": "Ваши существующие визиты остаются, и они сохраняют это имя.", "deleteFailed": "Запись не удалось удалить.", - "noSpecialtyGroup": "Без специальности" + "noSpecialtyGroup": "Без специальности", + "deleted": "Запись удалена", + "restoreFailed": "Запись не удалось восстановить." }, "links": { "picker": { diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 44e33938c..18636c146 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -216,6 +216,7 @@ const PINNED_AFFORDANCES: Record< "src/components/documents/sources/document-source-picker.tsx": { "text-success": 1, }, + "src/components/encounters/encounter-sheet.tsx": { "toast.success": 1 }, "src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 }, "src/components/illness/episode-menu.tsx": { CheckCircle2: 2, @@ -347,8 +348,10 @@ const PINNED_AFFORDANCES: Record< "src/components/symptoms/symptom-entry-form.tsx": { "toast.success": 1 }, "src/components/mood/mood-list.tsx": { "toast.success": 3 }, "src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 }, + "src/components/practitioners/practitioner-list.tsx": { "toast.success": 1 }, "src/components/records/allergy-form.tsx": { "toast.success": 1 }, "src/components/records/allergy-manager.tsx": { "toast.success": 1 }, + "src/components/vaccinations/vaccination-sheet.tsx": { "toast.success": 1 }, "src/components/records/conditions-manager.tsx": { "toast.success": 1 }, "src/components/records/family-history-form.tsx": { "toast.success": 1 }, "src/components/records/family-history-manager.tsx": { "toast.success": 1 }, diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 378636604..8ce3a0bf1 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -33,6 +33,7 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { @@ -85,7 +86,7 @@ export function EncounterSheet({ onSaved?: (saved: Encounter) => void; }) { const { t } = useTranslations(); - const { create, update, remove } = useEncounterMutations(); + const { create, update, remove, restore } = useEncounterMutations(); const [confirmingDelete, setConfirmingDelete] = useState(false); // Seeded once, at mount. The caller remounts this component on every open by // varying its `key`, so a second edit never shows the first one's values and @@ -210,8 +211,25 @@ export function EncounterSheet({ { - remove.mutate(encounter.id, { - onSuccess: () => onOpenChange(false), + const id = encounter.id; + remove.mutate(id, { + onSuccess: () => { + onOpenChange(false); + // The delete is soft. The Undo runs after this sheet + // closed, so it awaits the restore itself rather than + // passing callbacks a closed sheet never receives. + toast.success(t("encounters.deleted"), { + action: { + label: t("common.undo"), + onClick: () => + void restore.mutateAsync(id).catch((failure) => + showErrorToast(t("encounters.restoreFailed"), { + cause: failure, + }), + ), + }, + }); + }, onError: (failure) => showErrorToast(t("encounters.deleteFailed"), { cause: failure, diff --git a/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts b/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts new file mode 100644 index 000000000..fbe6aa48c --- /dev/null +++ b/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts @@ -0,0 +1,63 @@ +/** + * Deleting an address-book entry is a soft delete with a restore route, and + * nothing on the web offered it: a mistaken delete could not be taken back. + * The delete now confirms with an Undo that calls the restore. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const toastSuccess = vi.hoisted(() => vi.fn()); +const errorToast = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ toast: { success: toastSuccess } })); +vi.mock("@/lib/errors/error-toast", () => ({ showErrorToast: errorToast })); + +import { deleteWithUndo } from "../practitioner-list"; + +type Mutations = Parameters[0]; + +const t = (key: string) => key; + +function mutations(restoreResult: Promise) { + const remove = { + mutate: vi.fn((_id: string, options: { onSuccess?: () => void }) => + options.onSuccess?.(), + ), + }; + const restore = { mutateAsync: vi.fn(() => restoreResult) }; + return { remove, restore, all: { remove, restore } as unknown as Mutations }; +} + +beforeEach(() => { + toastSuccess.mockReset(); + errorToast.mockReset(); +}); + +describe("deleteWithUndo", () => { + it("confirms the delete with an Undo that restores the entry", async () => { + const m = mutations(Promise.resolve({ id: "p1" })); + + deleteWithUndo(m.all, "p1", t); + + expect(m.remove.mutate).toHaveBeenCalledWith("p1", expect.anything()); + const [message, options] = toastSuccess.mock.calls[0]!; + expect(message).toBe("practitioners.deleted"); + expect(options.action.label).toBe("common.undo"); + options.action.onClick(); + expect(m.restore.mutateAsync).toHaveBeenCalledWith("p1"); + await Promise.resolve(); + expect(errorToast).not.toHaveBeenCalled(); + }); + + it("says so when the restore fails", async () => { + const m = mutations(Promise.reject(new Error("offline"))); + + deleteWithUndo(m.all, "p1", t); + toastSuccess.mock.calls[0]![1].action.onClick(); + + await vi.waitFor(() => + expect(errorToast).toHaveBeenCalledWith( + "practitioners.restoreFailed", + expect.anything(), + ), + ); + }); +}); diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index b305ed15a..af361afe1 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -13,6 +13,7 @@ */ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; +import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { @@ -43,6 +44,33 @@ import { VISITS_PATH } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { PractitionerSheet } from "./practitioner-sheet"; +/** + * Delete an entry, with an Undo: the delete is soft, and the restore brings + * the entry back. + */ +export function deleteWithUndo( + { remove, restore }: ReturnType, + id: string, + t: (key: string) => string, +) { + remove.mutate(id, { + onSuccess: () => + toast.success(t("practitioners.deleted"), { + action: { + label: t("common.undo"), + onClick: () => + void restore.mutateAsync(id).catch((failure) => + showErrorToast(t("practitioners.restoreFailed"), { + cause: failure, + }), + ), + }, + }), + onError: (failure) => + showErrorToast(t("practitioners.deleteFailed"), { cause: failure }), + }); +} + export function PractitionerList({ enabled = true }: { enabled?: boolean }) { const { t } = useTranslations(); const { canWriteDomain, canManageDomain } = useRecordCapabilities(); @@ -52,7 +80,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { const [term, setTerm] = useState(""); const debounced = useDebouncedValue(term, 250); const list = usePractitioners(debounced, enabled); - const { remove } = usePractitionerMutations(); + const mutations = usePractitionerMutations(); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); @@ -257,12 +285,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { if (!deleting) return; const id = deleting.id; setDeleting(null); - remove.mutate(id, { - onError: (failure) => - showErrorToast(t("practitioners.deleteFailed"), { - cause: failure, - }), - }); + deleteWithUndo(mutations, id, t); }} > {t("common.delete")} diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 7bc0cbb6a..a63972299 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -20,6 +20,7 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { @@ -69,7 +70,7 @@ export function VaccinationSheet({ onCreated?: (created: Vaccination) => void; }) { const { t } = useTranslations(); - const { create, update, remove } = useVaccinationMutations(); + const { create, update, remove, restore } = useVaccinationMutations(); // The list row carries no document links; the detail read does. Until it // answers, the draft's links stay "untouched" and Save leaves them alone. const detail = useVaccination(vaccination?.id ?? null); @@ -198,8 +199,28 @@ export function VaccinationSheet({ { - remove.mutate(vaccination.id, { - onSuccess: () => onOpenChange(false), + const id = vaccination.id; + remove.mutate(id, { + onSuccess: () => { + onOpenChange(false); + // The delete is soft. The Undo runs after this sheet + // closed, so it awaits the restore itself rather than + // passing callbacks a closed sheet never receives. + toast.success(t("vaccinations.deleted"), { + action: { + label: t("common.undo"), + onClick: () => + void restore.mutateAsync(id).catch((failure) => + showErrorToast( + t("vaccinations.restoreFailed"), + { + cause: failure, + }, + ), + ), + }, + }); + }, onError: (failure) => showErrorToast(t("vaccinations.deleteFailed"), { cause: failure, diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index 6cb207374..a9cccc0b1 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -19,7 +19,13 @@ import { useQueryClient, } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiGetList, apiPatch } from "@/lib/api/api-fetch"; +import { + apiDelete, + apiGet, + apiGetList, + apiPatch, + apiPost, +} from "@/lib/api/api-fetch"; import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import { @@ -192,5 +198,13 @@ export function useEncounterMutations() { onSuccess: invalidate, }); - return { create, update, remove }; + // The Undo of a delete. A restored visit re-anchors its reminder only + // while its date is still ahead. + const restore = useMutation({ + mutationKey: queryKeys.encounterRestore(), + mutationFn: (id: string) => apiPost(`${BASE}/${id}/restore`, {}), + onSuccess: invalidate, + }); + + return { create, update, remove, restore }; } diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index 4b083a47f..3fdd4145e 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -91,5 +91,14 @@ export function usePractitionerMutations() { onSuccess: invalidate, }); - return { create, update, remove }; + // The Undo of a delete. It brings the entry back, not the links the + // delete cleared on the visits that named it. + const restore = useMutation({ + mutationKey: queryKeys.practitionerRestore(), + mutationFn: (id: string) => + apiPost(`${BASE}/${id}/restore`, {}), + onSuccess: invalidate, + }); + + return { create, update, remove, restore }; } diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index 84fab90e2..7abc4b10a 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -95,7 +95,9 @@ export const encounterKeys = { encounterCreate: () => ["encounters", "create"] as const, encounterUpdate: () => ["encounters", "update"] as const, encounterDelete: () => ["encounters", "delete"] as const, + encounterRestore: () => ["encounters", "restore"] as const, practitionerCreate: () => ["practitioners", "create"] as const, practitionerUpdate: () => ["practitioners", "update"] as const, practitionerDelete: () => ["practitioners", "delete"] as const, + practitionerRestore: () => ["practitioners", "restore"] as const, };