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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-29 00:53:23 +02:00
1 parent 167cc3afa2
commit 353c8d7fa0
11 files changed
+1014 -13

No files matched your search

+36 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
</>
);
}