From d1f6488b38135909a18a5bb70663026c749c672e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 20:36:11 +0200 Subject: [PATCH] fix(medications): the supply tab gets a delete affordance and honest control styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containers can be removed from the supply tab — the endpoint existed, the UI never offered it; a stamped container deleted later was already refund-tolerant. The adjust trigger wears the outline button vocabulary instead of reading as dead text, and the state badge moves inline next to the dose figures as plain meta text instead of posing as a button in the action cluster. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../inventory-section-actions.test.tsx | 124 ++++++++++++++++++ .../sections/inventory-section.tsx | 69 +++++++--- 8 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 src/components/medications/__tests__/inventory-section-actions.test.tsx diff --git a/messages/de.json b/messages/de.json index 3dcafb78a..1856da5b6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1616,6 +1616,10 @@ "adjustSubmit": "Speichern", "adjustSuccess": "Bestand aktualisiert.", "adjustFailed": "Bestand konnte nicht aktualisiert werden.", + "deleteTitle": "Packung löschen?", + "deleteDescription": "Entfernt diese Packung oder diesen Behälter dauerhaft aus dem Bestand. Protokollierte Einnahmen bleiben unverändert.", + "deleteSuccess": "Bestand gelöscht.", + "deleteFailed": "Bestand konnte nicht gelöscht werden.", "containerTypeLabel": "Behälterart", "containerType": { "PEN": "Pen", diff --git a/messages/en.json b/messages/en.json index a9ea8f561..d6ce05052 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1616,6 +1616,10 @@ "adjustSubmit": "Save", "adjustSuccess": "Stock updated.", "adjustFailed": "The stock could not be updated.", + "deleteTitle": "Delete container?", + "deleteDescription": "Permanently removes this pack or container from the stock. Logged intakes stay unchanged.", + "deleteSuccess": "Stock entry deleted.", + "deleteFailed": "The stock entry could not be deleted.", "containerTypeLabel": "Container type", "containerType": { "PEN": "Pen", diff --git a/messages/es.json b/messages/es.json index 63018e39e..d80ac5cbb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1616,6 +1616,10 @@ "adjustSubmit": "Guardar", "adjustSuccess": "Existencias actualizadas.", "adjustFailed": "No se pudieron actualizar las existencias.", + "deleteTitle": "¿Eliminar envase?", + "deleteDescription": "Elimina permanentemente este envase o recipiente de las existencias. Las tomas registradas no cambian.", + "deleteSuccess": "Entrada de existencias eliminada.", + "deleteFailed": "No se pudo eliminar la entrada de existencias.", "containerTypeLabel": "Tipo de envase", "containerType": { "PEN": "Pluma", diff --git a/messages/fr.json b/messages/fr.json index 9e24dfa09..3bcc9b3e8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1616,6 +1616,10 @@ "adjustSubmit": "Enregistrer", "adjustSuccess": "Stock mis à jour.", "adjustFailed": "Le stock n'a pas pu être mis à jour.", + "deleteTitle": "Supprimer le contenant ?", + "deleteDescription": "Supprime définitivement cette boîte ou ce contenant du stock. Les prises enregistrées restent inchangées.", + "deleteSuccess": "Entrée de stock supprimée.", + "deleteFailed": "L'entrée de stock n'a pas pu être supprimée.", "containerTypeLabel": "Type de contenant", "containerType": { "PEN": "Stylo", diff --git a/messages/it.json b/messages/it.json index 692bf94fe..be32183ab 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1616,6 +1616,10 @@ "adjustSubmit": "Salva", "adjustSuccess": "Scorta aggiornata.", "adjustFailed": "Impossibile aggiornare la scorta.", + "deleteTitle": "Eliminare la confezione?", + "deleteDescription": "Rimuove definitivamente questa confezione o contenitore dalla scorta. Le assunzioni registrate restano invariate.", + "deleteSuccess": "Scorta eliminata.", + "deleteFailed": "Impossibile eliminare la scorta.", "containerTypeLabel": "Tipo di contenitore", "containerType": { "PEN": "Penna", diff --git a/messages/pl.json b/messages/pl.json index 03223cb51..a5cec9ca4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1616,6 +1616,10 @@ "adjustSubmit": "Zapisz", "adjustSuccess": "Zapas zaktualizowany.", "adjustFailed": "Nie udało się zaktualizować zapasu.", + "deleteTitle": "Usunąć opakowanie?", + "deleteDescription": "Trwale usuwa to opakowanie lub pojemnik z zapasu. Zarejestrowane dawki pozostają bez zmian.", + "deleteSuccess": "Zapas usunięty.", + "deleteFailed": "Nie udało się usunąć zapasu.", "containerTypeLabel": "Rodzaj pojemnika", "containerType": { "PEN": "Wstrzykiwacz", diff --git a/src/components/medications/__tests__/inventory-section-actions.test.tsx b/src/components/medications/__tests__/inventory-section-actions.test.tsx new file mode 100644 index 000000000..f691f49c0 --- /dev/null +++ b/src/components/medications/__tests__/inventory-section-actions.test.tsx @@ -0,0 +1,124 @@ +/** + * v1.16.11 — Bestand item-row action cluster. + * + * Renders `` with a seeded query cache and pins the + * per-container row contract: + * + * - the adjust trigger is a visible secondary affordance + * (`variant="outline"`), not a bare text label; + * - every row carries a delete affordance behind a destructive + * confirm (AlertDialog trigger, 44px touch target); + * - the state badge sits inline on the meta line next to the + * dose/unit figures — a non-interactive span, never a button. + * + * Project convention is SSR-only component tests (`renderToStaticMarkup`). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +const useQueryMock = vi.fn(); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (...args: unknown[]) => useQueryMock(...args), + useQueryClient: () => ({ + invalidateQueries: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const { InventorySection } = await import("../sections/inventory-section"); + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +function seedItems(items: unknown[]) { + useQueryMock.mockReturnValue({ + data: { items, meta: { total: items.length } }, + isLoading: false, + }); +} + +describe(" — item-row actions", () => { + it("renders the adjust trigger as an outline button, not a bare label", () => { + seedItems([ + { + id: "i1", + state: "ACTIVE", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 4, + }, + ]); + const html = render( + , + ); + const adjust = html.match( + /]*data-slot="inventory-adjust-button"[^>]*>/, + ); + expect(adjust).not.toBeNull(); + expect(adjust![0]).toContain('data-variant="outline"'); + }); + + it("renders a per-row delete affordance behind a confirm trigger", () => { + seedItems([ + { + id: "i1", + state: "ACTIVE", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 4, + }, + { + id: "i2", + state: "IN_USE", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 2, + }, + ]); + const html = render( + , + ); + // One AlertDialog trigger per row, labelled with the shared delete copy. + const triggers = html.match(/aria-label="Löschen"/g) ?? []; + expect(triggers).toHaveLength(2); + expect(html).toContain("alert-dialog-trigger"); + }); + + it("places the state badge inline on the meta line as a non-interactive span", () => { + seedItems([ + { + id: "i1", + state: "ACTIVE", + containerType: "PEN", + unitsTotal: 4, + unitsRemaining: 4, + }, + ]); + const html = render( + , + ); + const badge = html.match( + /<(\w+)[^>]*data-slot="inventory-state-badge"[^>]*>/, + ); + expect(badge).not.toBeNull(); + expect(badge![1]).toBe("span"); + expect(badge![0]).toContain("text-xs"); + // The badge renders before the action cluster — inline with the + // meta figures, not floated next to the buttons. + const badgeIdx = html.indexOf('data-slot="inventory-state-badge"'); + const dosesIdx = html.indexOf("4 / 4 Dosen"); + const adjustIdx = html.indexOf('data-slot="inventory-adjust-button"'); + expect(dosesIdx).toBeGreaterThan(-1); + expect(badgeIdx).toBeGreaterThan(dosesIdx); + expect(badgeIdx).toBeLessThan(adjustIdx); + }); +}); diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 16114f651..13b56094b 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -15,6 +15,10 @@ * the count is set absolutely, clamped server-side to the item's * capacity, and the canonical state machine derives the next state * (0 ⇒ used up). + * - DELETE: a per-item trash affordance behind a destructive confirm + * (`DELETE …/inventory/[itemId]`). Consumption stamps on intake + * events that referenced the container stay in place; a later + * restore skips the missing item. * * v1.16.10 — items count UNITS (tablets / ampoules / puffs); * `Medication.unitsPerDose` maps units to doses. Every dose-facing @@ -38,6 +42,7 @@ import { Loader2, PackageOpen, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { DeleteButton } from "@/components/data-list/delete-button"; import { Dialog, DialogContent, @@ -58,7 +63,7 @@ import { import { SettingsGroup } from "@/components/medications/settings-group"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { summariseSupply } from "@/lib/medications/inventory/summary"; type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; @@ -115,9 +120,22 @@ export function InventorySection({ deliveryForm?: string; }) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [addOpen, setAddOpen] = useState(false); const [adjustItem, setAdjustItem] = useState(null); + async function deleteItem(item: InventoryItem) { + try { + await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`); + await queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }); + toast.success(t("medications.detail.bestand.deleteSuccess")); + } catch { + toast.error(t("medications.detail.bestand.deleteFailed")); + } + } + const perDose = Math.max(1, unitsPerDose ?? 1); const { data, isLoading } = useQuery({ @@ -256,28 +274,36 @@ export function InventorySection({ `medications.detail.bestand.containerType.${item.containerType}`, )} - - {t("medications.detail.bestand.doses", { - remaining: Math.floor(item.unitsRemaining / perDose), - total: Math.floor(item.unitsTotal / perDose), - })} - {perDose > 1 && ( - <> - {" · "} - {t("medications.detail.bestand.unitsDetail", { - remaining: item.unitsRemaining, - total: item.unitsTotal, - })} - - )} + {/* Meta line: per-container figures with the state badge + inline at meta-text size — read-only, never a control. */} + + + {t("medications.detail.bestand.doses", { + remaining: Math.floor(item.unitsRemaining / perDose), + total: Math.floor(item.unitsTotal / perDose), + })} + {perDose > 1 && ( + <> + {" · "} + {t("medications.detail.bestand.unitsDetail", { + remaining: item.unitsRemaining, + total: item.unitsTotal, + })} + + )} + + + {t(`medications.detail.bestand.state.${item.state}`)} + - - {t(`medications.detail.bestand.state.${item.state}`)} - + void deleteItem(item)} + title={t("medications.detail.bestand.deleteTitle")} + description={t("medications.detail.bestand.deleteDescription")} + /> ))}