mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(meds): row-level edit and bulk-delete on the intake history preview
Per-row Bearbeiten and Löschen actions sit behind a kebab on each intake row; multi-select gates a bulk-delete toolbar that wires through to the v1.5.5 bulk-delete endpoint. Closes the two displaced features from the v1.5.4 flat-form retirement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
167cc3afa2
commit
353c8d7fa0
11 files changed
+1014
-13
No files matched your search
+36
-1
@@ -1139,7 +1139,42 @@
|
||||
"intake": {
|
||||
"title": "Einnahmeverlauf",
|
||||
"importButton": "Importieren",
|
||||
"viewAllLink": "Vollständigen Verlauf öffnen →"
|
||||
"viewAllLink": "Vollständigen Verlauf öffnen →",
|
||||
"rowActions": {
|
||||
"openMenu": "Zeilen-Aktionen öffnen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Zeile auswählen"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} Einträge ausgewählt",
|
||||
"deleteButton": "Auswahl löschen",
|
||||
"cancelButton": "Abbrechen",
|
||||
"confirmTitle": "Ausgewählte Einträge löschen?",
|
||||
"confirmBody": "{count} Einträge werden dauerhaft entfernt. Das Medikament bleibt.",
|
||||
"confirmAction": "Löschen",
|
||||
"toast": "Auswahl gelöscht",
|
||||
"failed": "Auswahl konnte nicht gelöscht werden."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Einnahme bearbeiten",
|
||||
"takenAtLabel": "Eingenommen um",
|
||||
"skippedLabel": "Übersprungen",
|
||||
"noteLabel": "Notiz",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"savedToast": "Einnahme aktualisiert",
|
||||
"failed": "Einnahme konnte nicht aktualisiert werden."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Diese Einnahme löschen?",
|
||||
"confirmBody": "Der Eintrag wird dauerhaft entfernt.",
|
||||
"confirmAction": "Löschen",
|
||||
"toast": "Einnahme gelöscht",
|
||||
"failed": "Einnahme konnte nicht gelöscht werden."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Erinnerungen",
|
||||
|
||||
+36
-1
@@ -1139,7 +1139,42 @@
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →"
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
+36
-1
@@ -1139,7 +1139,42 @@
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →"
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
+36
-1
@@ -1139,7 +1139,42 @@
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →"
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
+36
-1
@@ -1139,7 +1139,42 @@
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →"
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
+36
-1
@@ -1139,7 +1139,42 @@
|
||||
"intake": {
|
||||
"title": "Intake history",
|
||||
"importButton": "Import",
|
||||
"viewAllLink": "View full history →"
|
||||
"viewAllLink": "View full history →",
|
||||
"rowActions": {
|
||||
"openMenu": "Open row actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"selection": {
|
||||
"rowToggleLabel": "Select row"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"selectionCount": "{count} entries selected",
|
||||
"deleteButton": "Delete selection",
|
||||
"cancelButton": "Cancel",
|
||||
"confirmTitle": "Delete the selected entries?",
|
||||
"confirmBody": "{count} entries will be removed permanently. The medication itself stays.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Selection deleted",
|
||||
"failed": "Could not delete the selection."
|
||||
},
|
||||
"edit": {
|
||||
"dialogTitle": "Edit intake",
|
||||
"takenAtLabel": "Taken at",
|
||||
"skippedLabel": "Skipped",
|
||||
"noteLabel": "Note",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"savedToast": "Intake updated",
|
||||
"failed": "Could not update the intake."
|
||||
},
|
||||
"deleteRow": {
|
||||
"confirmTitle": "Delete this intake?",
|
||||
"confirmBody": "The entry is removed permanently.",
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — `<IntakeHistoryListV2>` row-actions + selection
|
||||
* contract.
|
||||
*
|
||||
* Mirrors the SSR + `setQueryData` seed pattern the v1.4.36 W4a tests
|
||||
* use. We assert the rendered markup carries the kebab when the
|
||||
* caller passes `onEditIntake` / `onDeleteIntake`, the leading
|
||||
* checkbox when `selection.mode === "multi"`, and that the absence of
|
||||
* the callbacks leaves the table byte-identical to the v1.4.36 shape.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type IntakeRow = {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: "WEB" | "API" | "REMINDER" | "IMPORT";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function seed(client: QueryClient, medId: string, rows: IntakeRow[]) {
|
||||
client.setQueryData(
|
||||
queryKeys.medicationIntakeList(medId, {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc",
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
}),
|
||||
{ events: rows, meta: { total: rows.length, limit: 25, offset: 0 } },
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client: QueryClient) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const ROW: IntakeRow = {
|
||||
id: "evt-row",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — F-1 C-2 row actions", () => {
|
||||
it("does NOT render the kebab when no callbacks are passed (back-compat)", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain("intake-history-row-kebab");
|
||||
});
|
||||
|
||||
it("renders the kebab trigger when onEditIntake is passed", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2
|
||||
medicationId="med-1"
|
||||
onEditIntake={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("intake-history-row-kebab");
|
||||
});
|
||||
|
||||
it("renders the kebab trigger when onDeleteIntake is passed", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2
|
||||
medicationId="med-1"
|
||||
onDeleteIntake={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("intake-history-row-kebab");
|
||||
});
|
||||
|
||||
it("does NOT render the selection column when selection prop is absent", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain("intake-history-row-select");
|
||||
});
|
||||
|
||||
it("renders a leading checkbox per row when selection.mode === 'multi'", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", [ROW]);
|
||||
const html = render(
|
||||
<IntakeHistoryListV2
|
||||
medicationId="med-1"
|
||||
selection={{
|
||||
mode: "multi",
|
||||
selected: new Set(),
|
||||
onToggle: () => {},
|
||||
}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain("intake-history-row-select");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — `<IntakeHistoryPreview>` smoke tests.
|
||||
*
|
||||
* The preview mounts in SSR with an empty selection so the bulk-
|
||||
* delete toolbar stays hidden by default. Selection / dialog flips
|
||||
* are interactive surfaces and are exercised by the e2e suite.
|
||||
*
|
||||
* The kebab + selection wiring is covered by
|
||||
* `intake-history-list-v2-row-actions.test.tsx`; this file pins the
|
||||
* wrapper-level contract (no toolbar by default, wired through the
|
||||
* v1.5.5 endpoints).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function seed(client: QueryClient) {
|
||||
client.setQueryData(
|
||||
queryKeys.medicationIntakeList("med-1", {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc",
|
||||
limit: 14,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
}),
|
||||
{
|
||||
events: [
|
||||
{
|
||||
id: "evt-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-05-15T08:00:00.000Z",
|
||||
takenAt: "2026-05-15T08:02:00.000Z",
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
createdAt: "2026-05-15T08:02:00.000Z",
|
||||
},
|
||||
],
|
||||
meta: { total: 1, limit: 14, offset: 0 },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client: QueryClient) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<IntakeHistoryPreview> — F-1 C-2", () => {
|
||||
it("hides the bulk-delete toolbar when nothing is selected (initial render)", () => {
|
||||
const client = makeClient();
|
||||
seed(client);
|
||||
const html = render(
|
||||
<IntakeHistoryPreview
|
||||
medicationId="med-1"
|
||||
importOpen={false}
|
||||
onImportOpenChange={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain("intake-history-bulk-delete-toolbar");
|
||||
});
|
||||
|
||||
it("renders the row-action kebab and selection checkbox on every row", () => {
|
||||
const client = makeClient();
|
||||
seed(client);
|
||||
const html = render(
|
||||
<IntakeHistoryPreview
|
||||
medicationId="med-1"
|
||||
importOpen={false}
|
||||
onImportOpenChange={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
// Per-row affordances feature 15 + 16 — the row carries both.
|
||||
expect(html).toContain("intake-history-row-kebab");
|
||||
expect(html).toContain("intake-history-row-select");
|
||||
// The import header CTA stays alongside the new affordances.
|
||||
expect(html).toContain("intake-history-import");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Bearbeiten dialog on the detail-page
|
||||
* intake-history preview.
|
||||
*
|
||||
* Restores the row-level edit affordance the v1.5.4 flat-form
|
||||
* retirement displaced (I-1 §15). The dialog opens from the
|
||||
* intake-history kebab; fields are `takenAt` (datetime-local),
|
||||
* `skipped` (boolean), `note` (string?). On save it PATCHes
|
||||
* `/api/medications/{id}/intake/{eventId}` and fires the
|
||||
* medication cache bundle so the inline compliance tile + dashboard
|
||||
* chart converge in the same tick.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface IntakeEditDialogProps {
|
||||
medicationId: string;
|
||||
/** Event being edited; `null` closes the dialog. */
|
||||
event: {
|
||||
id: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
note?: string | null;
|
||||
} | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ISO timestamp into the `<input type="datetime-local">`
|
||||
* shape `YYYY-MM-DDTHH:mm`. Returns an empty string for null inputs.
|
||||
* The string is rendered in the user's local time so the picker
|
||||
* matches the dose timestamp the user remembers.
|
||||
*/
|
||||
function toDateTimeLocal(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
|
||||
d.getDate(),
|
||||
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function IntakeEditDialog(props: IntakeEditDialogProps) {
|
||||
// Re-mount the body whenever the event id flips so the form state
|
||||
// initialises from the row the user clicked. The wrapper is also
|
||||
// responsible for not rendering the dialog when `event` is null.
|
||||
if (!props.event) return null;
|
||||
return <IntakeEditDialogBody key={props.event.id} {...props} />;
|
||||
}
|
||||
|
||||
function IntakeEditDialogBody({
|
||||
medicationId,
|
||||
event,
|
||||
onClose,
|
||||
}: IntakeEditDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [takenAt, setTakenAt] = useState(() =>
|
||||
toDateTimeLocal(event?.takenAt ?? null),
|
||||
);
|
||||
const [skipped, setSkipped] = useState(() => event?.skipped ?? false);
|
||||
const [note, setNote] = useState(() => event?.note ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
async function save() {
|
||||
if (!event || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
skipped,
|
||||
};
|
||||
if (takenAt) {
|
||||
// Re-attach the local-time string to ISO with the user's
|
||||
// current tz offset so the server stores the canonical UTC
|
||||
// value the picker showed.
|
||||
body.takenAt = new Date(takenAt).toISOString();
|
||||
} else {
|
||||
body.takenAt = null;
|
||||
}
|
||||
if (note.trim().length > 0) {
|
||||
body.note = note;
|
||||
}
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/${event.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
return;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.intake.edit.savedToast"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!event} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent data-slot="intake-edit-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.intake.edit.dialogTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="intake-edit-taken-at">
|
||||
{t("medications.detail.intake.edit.takenAtLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="intake-edit-taken-at"
|
||||
type="datetime-local"
|
||||
value={takenAt}
|
||||
onChange={(e) => setTakenAt(e.target.value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="intake-edit-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.detail.intake.edit.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="intake-edit-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={(checked) => setSkipped(checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="intake-edit-note">
|
||||
{t("medications.detail.intake.edit.noteLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="intake-edit-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>
|
||||
{t("medications.detail.intake.edit.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={busy} aria-busy={busy || undefined}>
|
||||
{t("medications.detail.intake.edit.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
History,
|
||||
Check,
|
||||
SkipForward,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -19,6 +20,13 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -80,10 +88,38 @@ interface IntakeListResponse {
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — optional selection contract. When the wrapper
|
||||
* passes `selection`, each row renders a leading `<Checkbox>` that
|
||||
* mirrors the selected set. Single mode hides the bulk-toolbar; multi
|
||||
* mode wires through to `<BulkDeleteToolbar>`. The contract lives on
|
||||
* the list rather than on the wrapper so a future surface (e.g. the
|
||||
* full /history page) can re-use the same shape.
|
||||
*/
|
||||
export interface IntakeHistoryListV2Selection {
|
||||
mode: "single" | "multi";
|
||||
selected: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
interface IntakeHistoryListV2Props {
|
||||
medicationId: string;
|
||||
/** Override the default page size — primarily for tests. */
|
||||
pageSize?: number;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Bearbeiten callback. When provided the
|
||||
* row renders a kebab `<DropdownMenu>` with an Edit action.
|
||||
*/
|
||||
onEditIntake?: (eventId: string) => void;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — per-row Löschen callback. When provided the row
|
||||
* renders a kebab `<DropdownMenu>` with a Delete action.
|
||||
*/
|
||||
onDeleteIntake?: (eventId: string) => void;
|
||||
/**
|
||||
* v1.5.5 F-1 C-2 — selection contract; see `IntakeHistoryListV2Selection`.
|
||||
*/
|
||||
selection?: IntakeHistoryListV2Selection;
|
||||
}
|
||||
|
||||
type SortKey = "takenAt" | "scheduledFor";
|
||||
@@ -100,11 +136,20 @@ const STATUS_FILTER = "completed";
|
||||
export function IntakeHistoryListV2({
|
||||
medicationId,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
onEditIntake,
|
||||
onDeleteIntake,
|
||||
selection,
|
||||
}: IntakeHistoryListV2Props) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
|
||||
// v1.5.5 F-1 C-2 — the kebab + checkbox columns are gated by the
|
||||
// caller-passed callbacks. The list never assumes a wrapper; it
|
||||
// renders the extra columns only when explicitly opted-in.
|
||||
const showRowActions = Boolean(onEditIntake || onDeleteIntake);
|
||||
const showSelection = selection?.mode === "multi";
|
||||
|
||||
const [sortBy, setSortBy] = useState<SortKey>("takenAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -207,6 +252,12 @@ export function IntakeHistoryListV2({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{showSelection && (
|
||||
<TableHead
|
||||
className="w-8"
|
||||
data-slot="intake-history-select-col"
|
||||
/>
|
||||
)}
|
||||
<TableHead>
|
||||
<button
|
||||
type="button"
|
||||
@@ -235,6 +286,12 @@ export function IntakeHistoryListV2({
|
||||
<TableHead>
|
||||
{t("medications.intakeHistoryColSource")}
|
||||
</TableHead>
|
||||
{showRowActions && (
|
||||
<TableHead
|
||||
className="w-8"
|
||||
data-slot="intake-history-actions-col"
|
||||
/>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -248,8 +305,23 @@ export function IntakeHistoryListV2({
|
||||
// sneaks past as "Eingenommen".
|
||||
const isTaken = !event.skipped && !!event.takenAt;
|
||||
const isSkipped = event.skipped;
|
||||
const isSelected = selection?.selected.has(event.id) ?? false;
|
||||
return (
|
||||
<TableRow key={event.id}>
|
||||
{showSelection && (
|
||||
<TableCell className="w-8">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() =>
|
||||
selection?.onToggle(event.id)
|
||||
}
|
||||
aria-label={t(
|
||||
"medications.detail.intake.selection.rowToggleLabel",
|
||||
)}
|
||||
data-slot="intake-history-row-select"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-sm">
|
||||
{event.takenAt
|
||||
? formatters.dateTime(event.takenAt)
|
||||
@@ -285,6 +357,50 @@ export function IntakeHistoryListV2({
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{showRowActions && (
|
||||
<TableCell className="w-8 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t(
|
||||
"medications.detail.intake.rowActions.openMenu",
|
||||
)}
|
||||
data-slot="intake-history-row-kebab"
|
||||
>
|
||||
<MoreHorizontal
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onEditIntake && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onEditIntake(event.id)}
|
||||
data-slot="intake-history-row-edit"
|
||||
>
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDeleteIntake && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDeleteIntake(event.id)}
|
||||
data-slot="intake-history-row-delete"
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{t(
|
||||
"medications.detail.intake.rowActions.delete",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,21 +9,42 @@
|
||||
* table + sort + pagination stay byte-identical; the preview adds a
|
||||
* footer link to the full `/medications/[id]/history` route.
|
||||
*
|
||||
* Phase-2 add-ons (multi-select bulk-delete + row kebab Bearbeiten /
|
||||
* Löschen) ride on top of the wrapped list. The bulk-delete uses the
|
||||
* v1.5.5 `POST /api/medications/[id]/intake/bulk-delete` endpoint and
|
||||
* fires `medicationDependentKeys` so the inline compliance tile +
|
||||
* rollup-tier dashboard chart converge in one tick.
|
||||
* v1.5.5 F-1 C-2 add-ons (Features 15 + 16 from I-1):
|
||||
* - Multi-select bulk-delete: the preview drives the
|
||||
* `<IntakeHistoryListV2>` selection contract and renders a
|
||||
* bulk-delete toolbar above the table when the user has at
|
||||
* least one row selected. POSTs to the v1.5.5 bulk-delete
|
||||
* endpoint then fires `medicationDependentKeys` so the inline
|
||||
* compliance tile + rollup-tier dashboard chart converge in one
|
||||
* tick.
|
||||
* - Per-row kebab: Bearbeiten opens an inline `<IntakeEditDialog>`
|
||||
* (PUT `/api/medications/{id}/intake/{eventId}`); Löschen fires
|
||||
* a single-step `<AlertDialog>` and DELETEs the same row.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
|
||||
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
|
||||
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface IntakeHistoryPreviewProps {
|
||||
medicationId: string;
|
||||
@@ -37,6 +58,91 @@ export function IntakeHistoryPreview({
|
||||
onImportOpenChange,
|
||||
}: IntakeHistoryPreviewProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [rowDeleteBusy, setRowDeleteBusy] = useState(false);
|
||||
|
||||
function onToggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
async function confirmBulkDelete() {
|
||||
if (bulkBusy || selected.size === 0) return;
|
||||
setBulkBusy(true);
|
||||
const ids = Array.from(selected);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/bulk-delete`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventIds: ids }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.detail.intake.bulkDelete.failed"));
|
||||
return;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.intake.bulkDelete.toast"));
|
||||
setBulkConfirmOpen(false);
|
||||
clearSelection();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.bulkDelete.failed"));
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRowDelete() {
|
||||
if (rowDeleteBusy || !pendingDeleteId) return;
|
||||
setRowDeleteBusy(true);
|
||||
const id = pendingDeleteId;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.detail.intake.deleteRow.failed"));
|
||||
return;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.intake.deleteRow.toast"));
|
||||
setPendingDeleteId(null);
|
||||
// Drop the deleted row from the pending selection so the
|
||||
// bulk-toolbar count stays honest.
|
||||
setSelected((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.deleteRow.failed"));
|
||||
} finally {
|
||||
setRowDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -58,7 +164,56 @@ export function IntakeHistoryPreview({
|
||||
}
|
||||
>
|
||||
<div className="space-y-3" data-slot="intake-history-preview-body">
|
||||
<IntakeHistoryListV2 medicationId={medicationId} pageSize={14} />
|
||||
{selected.size > 0 && (
|
||||
<div
|
||||
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
|
||||
role="region"
|
||||
aria-label={t(
|
||||
"medications.detail.intake.bulkDelete.selectionCount",
|
||||
{ count: selected.size },
|
||||
)}
|
||||
data-slot="intake-history-bulk-delete-toolbar"
|
||||
>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.intake.bulkDelete.selectionCount", {
|
||||
count: selected.size,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-bulk-cancel"
|
||||
>
|
||||
{t("medications.detail.intake.bulkDelete.cancelButton")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setBulkConfirmOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-bulk-delete"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.bulkDelete.deleteButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IntakeHistoryListV2
|
||||
medicationId={medicationId}
|
||||
pageSize={14}
|
||||
onEditIntake={setEditingId}
|
||||
onDeleteIntake={setPendingDeleteId}
|
||||
selection={{
|
||||
mode: "multi",
|
||||
selected,
|
||||
onToggle,
|
||||
}}
|
||||
/>
|
||||
<p className="text-right text-xs">
|
||||
<Link
|
||||
href={`/medications/${medicationId}/history`}
|
||||
@@ -75,6 +230,86 @@ export function IntakeHistoryPreview({
|
||||
medicationId={importOpen ? medicationId : null}
|
||||
onClose={() => onImportOpenChange(false)}
|
||||
/>
|
||||
|
||||
<IntakeEditDialog
|
||||
medicationId={medicationId}
|
||||
event={
|
||||
editingId
|
||||
? { id: editingId, takenAt: null, skipped: false }
|
||||
: null
|
||||
}
|
||||
onClose={() => setEditingId(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={bulkConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setBulkConfirmOpen(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.detail.intake.bulkDelete.confirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.detail.intake.bulkDelete.confirmBody", {
|
||||
count: selected.size,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bulkBusy}>
|
||||
{t("medications.detail.intake.bulkDelete.cancelButton")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void confirmBulkDelete();
|
||||
}}
|
||||
disabled={bulkBusy}
|
||||
aria-busy={bulkBusy || undefined}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("medications.detail.intake.bulkDelete.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!pendingDeleteId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDeleteId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.detail.intake.deleteRow.confirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.detail.intake.deleteRow.confirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={rowDeleteBusy}>
|
||||
{t("medications.detail.intake.bulkDelete.cancelButton")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void confirmRowDelete();
|
||||
}}
|
||||
disabled={rowDeleteBusy}
|
||||
aria-busy={rowDeleteBusy || undefined}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("medications.detail.intake.deleteRow.confirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user