mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(medications): the supply tab gets a delete affordance and honest control styling
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.
This commit is contained in:
1 parent
5859072561
commit
d1f6488b38
8 files changed
+198
-19
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* v1.16.11 — Bestand item-row action cluster.
|
||||
*
|
||||
* Renders `<InventorySection>` 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(
|
||||
<I18nProvider initialLocale="de">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function seedItems(items: unknown[]) {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: { items, meta: { total: items.length } },
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("<InventorySection> — 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(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
const adjust = html.match(
|
||||
/<button[^>]*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(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
// 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(
|
||||
<InventorySection medicationId="med-1" unitsPerDose={1} />,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<InventoryItem | null>(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<InventoryResponse>({
|
||||
@@ -256,28 +274,36 @@ export function InventorySection({
|
||||
`medications.detail.bestand.containerType.${item.containerType}`,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{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. */}
|
||||
<span className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||||
<span>
|
||||
{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,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={STATE_BADGE[item.state]}
|
||||
className="px-1.5 py-0 text-xs font-normal"
|
||||
data-slot="inventory-state-badge"
|
||||
>
|
||||
{t(`medications.detail.bestand.state.${item.state}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Badge variant={STATE_BADGE[item.state]} className="text-xs">
|
||||
{t(`medications.detail.bestand.state.${item.state}`)}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdjustItem(item)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -285,6 +311,11 @@ export function InventorySection({
|
||||
>
|
||||
{t("medications.detail.bestand.adjustButton")}
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => void deleteItem(item)}
|
||||
title={t("medications.detail.bestand.deleteTitle")}
|
||||
description={t("medications.detail.bestand.deleteDescription")}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user