diff --git a/messages/de.json b/messages/de.json index f36080e93..e56bfa9ea 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index bf505d9ae..cef1937d8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 1ce96c58b..7894dcee7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 106351abe..bd9492040 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index e3518d3fe..a6a0c65cc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 5c6280ea5..af81a8bdb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx b/src/components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx new file mode 100644 index 000000000..b55223064 --- /dev/null +++ b/src/components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx @@ -0,0 +1,148 @@ +/** + * v1.5.5 F-1 C-2 — `` 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( + + {node} + , + ); +} + +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(" — 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( + , + 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( + {}} + />, + 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( + {}} + />, + 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( + , + 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( + {}, + }} + />, + client, + ); + expect(html).toContain("intake-history-row-select"); + }); +}); diff --git a/src/components/medications/__tests__/intake-history-preview-bulk-toolbar.test.tsx b/src/components/medications/__tests__/intake-history-preview-bulk-toolbar.test.tsx new file mode 100644 index 000000000..dc52307ae --- /dev/null +++ b/src/components/medications/__tests__/intake-history-preview-bulk-toolbar.test.tsx @@ -0,0 +1,108 @@ +/** + * v1.5.5 F-1 C-2 — `` 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( + + {node} + , + ); +} + +describe(" — F-1 C-2", () => { + it("hides the bulk-delete toolbar when nothing is selected (initial render)", () => { + const client = makeClient(); + seed(client); + const html = render( + {}} + />, + 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( + {}} + />, + 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"); + }); +}); diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx new file mode 100644 index 000000000..ba1c3d168 --- /dev/null +++ b/src/components/medications/intake-edit-dialog.tsx @@ -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 `` + * 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 ; +} + +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 = { + 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 ( + !open && onClose()}> + + + + {t("medications.detail.intake.edit.dialogTitle")} + + +
+
+ + setTakenAt(e.target.value)} + disabled={skipped} + /> +
+ +
+ +