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")} + /> ))}