fix(ui): mobile stacked intake history, icon-collapse bulk bar, ResponsiveSheet parity, tap-target floor

Intake history stayed a 4-5 column table with two full datetime columns
on mobile, forcing sideways scroll of a primary history surface; add
the stacked card-list split below md (the measurement-list pattern) and
lift the row kebab off a hard 32px box onto the size-11/size-9 floor.

The documents bulk-action bar wrapped five labelled buttons into a
2-3 row floating slab at 360px; collapse to icon-only (aria-label
carries the name) below sm, matching the document-detail-sheet
footer's established pattern.

Two coach/medication modals were still on raw Dialog while every
sibling had moved to ResponsiveSheet: the schedule-era editor and the
document attachment picker. Both now share the bottom-sheet-on-mobile
primitive; the era editor's submit button associates to its form via
the native `form` attribute since the footer slot sits outside the
form's DOM subtree (the log-intake-dialog precedent).

Tap-target floor: side-effect entry chips and the coach attachment-pill
remove control were below the 44px floor; the chips get a min-h-11
flex-center, the pill remove gets an invisible hit-slop instead of
growing the pill's visual footprint. Two insights empty-state CTAs
were missing the existing `ctaSize="lg"` treatment already used by
their sibling empty states.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 03:52:32 +02:00
1 parent 66fe69b493
commit 5532a42ff6
7 files changed
+565 -397

No files matched your search

+2
View File
@@ -371,6 +371,7 @@ export default function InsightsPage() {
icon={<TrendingUp className="size-6" />}
title={t("insights.emptyTitle")}
description={t("insights.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements">
@@ -568,6 +569,7 @@ export default function InsightsPage() {
icon={<SlidersHorizontal className="size-6" />}
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/layout/insights">
+31 -7
View File
@@ -75,11 +75,23 @@ export function DocumentBulkBar({
</p>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
{/* v1.30 mobile audit (DO-1) — five labelled buttons wrapped into a
2–3-row floating slab at ~360 px. Below `sm` every verb collapses
to icon-only (`aria-label` carries the accessible name; the
visible label returns at `sm+`) — the `document-detail-sheet`
footer's established icon-collapse pattern. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.setKind")}
>
<Tag className="size-4" aria-hidden />
{t("documents.bulk.setKind")}
<span className="hidden sm:inline">
{t("documents.bulk.setKind")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -98,9 +110,16 @@ export function DocumentBulkBar({
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.linkCondition")}
>
<FolderPlus className="size-4" aria-hidden />
{t("documents.bulk.linkCondition")}
<span className="hidden sm:inline">
{t("documents.bulk.linkCondition")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -122,9 +141,10 @@ export function DocumentBulkBar({
disabled={busy}
onClick={onShare}
data-slot="document-bulk-share"
aria-label={t("documents.bulk.share")}
>
<Share2 className="size-4" aria-hidden />
{t("documents.bulk.share")}
<span className="hidden sm:inline">{t("documents.bulk.share")}</span>
</Button>
{/* Solid destructive (matching the detail sheet's Delete) — the
@@ -135,9 +155,10 @@ export function DocumentBulkBar({
size="sm"
disabled={busy}
onClick={onDelete}
aria-label={t("documents.bulk.delete")}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.bulk.delete")}
<span className="hidden sm:inline">{t("documents.bulk.delete")}</span>
</Button>
<Button
@@ -145,9 +166,12 @@ export function DocumentBulkBar({
size="sm"
className="text-muted-foreground"
onClick={onClear}
aria-label={t("documents.selection.clear")}
>
<X className="size-3.5" aria-hidden />
{t("documents.selection.clear")}
<span className="hidden sm:inline">
{t("documents.selection.clear")}
</span>
</Button>
</div>
</div>
@@ -5,14 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
@@ -128,18 +121,39 @@ export function AttachmentPicker({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.coach.attach.pickerTitle")}
description={t("insights.coach.attach.pickerDescription")}
contentWidth="lg"
footer={
<>
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
data-slot="coach-attachment-picker-cancel"
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={selected.length === 0}
data-slot="coach-attachment-picker-confirm"
>
{t("insights.coach.attach.pickerConfirm", {
count: selected.length,
})}
</Button>
</>
}
>
<div
data-slot="coach-attachment-picker"
className="flex max-h-[85vh] flex-col sm:max-w-lg"
className="flex flex-col gap-3"
>
<DialogHeader>
<DialogTitle>{t("insights.coach.attach.pickerTitle")}</DialogTitle>
<DialogDescription>
{t("insights.coach.attach.pickerDescription")}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
@@ -163,97 +177,74 @@ export function AttachmentPicker({
{t("insights.coach.attach.pickerCap", { max: remainingSlots })}
</p>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
</p>
) : (
<ul className="space-y-1.5">
{documents.map((doc) => {
const isSelected = selected.includes(doc.id);
const selectable = doc.hasContentIndex;
const disabled = !selectable || (!isSelected && capReached);
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => selectable && toggle(doc.id)}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
{!selectable ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
</p>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{documents.map((doc) => {
const isSelected = selected.includes(doc.id);
const selectable = doc.hasContentIndex;
const disabled = !selectable || (!isSelected && capReached);
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => selectable && toggle(doc.id)}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
data-slot="coach-attachment-picker-cancel"
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={selected.length === 0}
data-slot="coach-attachment-picker-confirm"
>
{t("insights.coach.attach.pickerConfirm", {
count: selected.length,
{!selectable ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
);
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ul>
)}
</div>
</ResponsiveSheet>
);
}
@@ -85,7 +85,12 @@ export function AttachmentPills({
title: item.title,
})}
title={t("insights.coach.attach.pillRemove", { title: item.title })}
className="text-muted-foreground hover:text-foreground size-5 shrink-0 rounded-full"
// v1.30 mobile audit (C-4) — the visible glyph stays a compact
// size-5 pill control, but the actual hit area extends to the
// 44px floor via an invisible `after` hit-slop (the same
// `measurement-list.tsx` checkbox trick) rather than growing the
// pill's visual footprint in the composer's attach row.
className="text-muted-foreground hover:text-foreground relative size-5 shrink-0 rounded-full after:absolute after:-inset-3"
>
<X className="size-3" aria-hidden="true" />
</Button>
@@ -15,12 +15,14 @@ import {
} from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
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 { ListRow } from "@/components/ui/list-row";
import {
DropdownMenu,
DropdownMenuContent,
@@ -160,6 +162,12 @@ export function IntakeHistoryListV2({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const formatters = useFormatters();
// v1.30 mobile audit (M-2) — the desktop table carries two full datetime
// columns plus status + source + kebab, forcing horizontal scroll on
// every phone. SSR-safe gate (mirrors `measurement-list.tsx`'s H3 split):
// resolves to the desktop table on the server + first paint, then flips
// to the stacked list after hydration.
const isMobile = useIsMobile("md");
// 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
@@ -265,182 +273,266 @@ export function IntakeHistoryListV2({
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
{showSelection && (
<TableHead
className="w-8"
data-slot="intake-history-select-col"
/>
)}
<TableHead>
<button
type="button"
onClick={() => toggleSort("takenAt")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByTaken")}
>
{t("medications.intakeHistoryColTaken")}
{sortIndicator("takenAt")}
</button>
</TableHead>
<TableHead>
<button
type="button"
onClick={() => toggleSort("scheduledFor")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByScheduled")}
>
{t("medications.intakeHistoryColScheduled")}
{sortIndicator("scheduledFor")}
</button>
</TableHead>
<TableHead>
{t("medications.intakeHistoryColStatus")}
</TableHead>
<TableHead>
{t("medications.intakeHistoryColSource")}
</TableHead>
{showRowActions && (
<TableHead
className="w-8"
data-slot="intake-history-actions-col"
/>
)}
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => {
// v1.4.37 W3 — `status=completed` server filter
// guarantees that every row is either a taken or a
// skipped event. We derive the branch from the data
// itself rather than trusting a single `skipped`
// flag so a malformed row (e.g. `skipped:false` with
// `takenAt:null`, the v1.4.36 regression) never
// 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">
{/* v1.7.0 — the date column is always populated:
taken rows show `takenAt`, skipped rows fall
back to `scheduledFor` with a muted
"(planned)" suffix so the chronological
order stays clean and no `—` row floats. */}
{event.takenAt ? (
formatters.dateTime(event.takenAt)
) : (
<>
{formatters.dateTime(event.scheduledFor)}{" "}
<span className="text-muted-foreground text-xs">
({t("medications.detail.history.plannedSuffix")})
</span>
</>
{/* v1.30 mobile audit (M-2) — the two-datetime-column table is
contained (Table self-wraps in overflow-x-auto) but forces
sideways scroll of a primary history surface on every phone.
Below `md` it swaps for a stacked card list (the
`measurement-list.tsx` pattern); only the active layout
mounts, mirroring that component's SSR-safe gate. */}
{!isMobile && (
<Table>
<TableHeader>
<TableRow>
{showSelection && (
<TableHead
className="w-8"
data-slot="intake-history-select-col"
/>
)}
<TableHead>
<button
type="button"
onClick={() => toggleSort("takenAt")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByTaken")}
>
{t("medications.intakeHistoryColTaken")}
{sortIndicator("takenAt")}
</button>
</TableHead>
<TableHead>
<button
type="button"
onClick={() => toggleSort("scheduledFor")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t(
"medications.intakeHistorySortByScheduled",
)}
</TableCell>
<TableCell className="text-sm">
{formatters.dateTime(event.scheduledFor)}
</TableCell>
<TableCell>
{isTaken ? (
<Badge
variant="secondary"
className="bg-success/15 text-success gap-1 text-xs"
>
<Check aria-hidden="true" className="h-3 w-3" />
{t("medications.intakeHistoryStatusTaken")}
</Badge>
) : isSkipped ? (
>
{t("medications.intakeHistoryColScheduled")}
{sortIndicator("scheduledFor")}
</button>
</TableHead>
<TableHead>
{t("medications.intakeHistoryColStatus")}
</TableHead>
<TableHead>
{t("medications.intakeHistoryColSource")}
</TableHead>
{showRowActions && (
<TableHead
className="w-8"
data-slot="intake-history-actions-col"
/>
)}
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => {
// v1.4.37 W3 — `status=completed` server filter
// guarantees that every row is either a taken or a
// skipped event. We derive the branch from the data
// itself rather than trusting a single `skipped`
// flag so a malformed row (e.g. `skipped:false` with
// `takenAt:null`, the v1.4.36 regression) never
// 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">
{/* v1.7.0 — the date column is always populated:
taken rows show `takenAt`, skipped rows fall
back to `scheduledFor` with a muted
"(planned)" suffix so the chronological
order stays clean and no `—` row floats. */}
{event.takenAt ? (
formatters.dateTime(event.takenAt)
) : (
<>
{formatters.dateTime(event.scheduledFor)}{" "}
<span className="text-muted-foreground text-xs">
(
{t(
"medications.detail.history.plannedSuffix",
)}
)
</span>
</>
)}
</TableCell>
<TableCell className="text-sm">
{formatters.dateTime(event.scheduledFor)}
</TableCell>
<TableCell>
{isTaken ? (
<Badge
variant="secondary"
className="bg-success/15 text-success gap-1 text-xs"
>
<Check aria-hidden="true" className="h-3 w-3" />
{t("medications.intakeHistoryStatusTaken")}
</Badge>
) : isSkipped ? (
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs"
>
<SkipForward
aria-hidden="true"
className="h-3 w-3"
/>
{t("medications.intakeHistoryStatusSkipped")}
</Badge>
) : null}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs"
className="text-muted-foreground text-xs font-normal"
>
<SkipForward
aria-hidden="true"
className="h-3 w-3"
/>
{t("medications.intakeHistoryStatusSkipped")}
{t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
</Badge>
) : null}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
{t("medications.intakeSourceVia", {
origin: 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)}
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>
{showRowActions && (
<TableCell className="w-8 text-right">
<IntakeRowActions
event={event}
onEditIntake={onEditIntake}
onDeleteIntake={onDeleteIntake}
t={t}
/>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
{isMobile && (
<div className="space-y-2" data-slot="intake-history-mobile">
{events.map((event) => {
const isTaken = !event.skipped && !!event.takenAt;
const isSkipped = event.skipped;
const isSelected =
selection?.selected.has(event.id) ?? false;
return (
<ListRow
key={event.id}
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-2">
{showSelection && (
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() =>
selection?.onToggle(event.id)
}
aria-label={t(
"medications.detail.intake.selection.rowToggleLabel",
)}
data-slot="intake-history-row-select"
className="relative after:absolute after:-inset-3.5"
/>
</div>
)}
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">
{event.takenAt ? (
formatters.dateTime(event.takenAt)
) : (
<>
{formatters.dateTime(event.scheduledFor)}{" "}
<span className="text-muted-foreground text-xs font-normal">
(
{t(
"medications.detail.history.plannedSuffix",
)}
)
</span>
</>
)}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.intakeHistoryColScheduled")}:{" "}
{formatters.dateTime(event.scheduledFor)}
</p>
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{isTaken ? (
<Badge
variant="secondary"
className="bg-success/15 text-success gap-1 text-xs"
>
<Check
aria-hidden="true"
className="h-3 w-3"
/>
{t("medications.intakeHistoryStatusTaken")}
</Badge>
) : isSkipped ? (
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs"
>
<SkipForward
aria-hidden="true"
className="h-3 w-3"
/>
{t(
"medications.intakeHistoryStatusSkipped",
)}
</Badge>
) : null}
<Badge
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
{t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
</Badge>
</div>
</div>
</div>
{showRowActions && (
<IntakeRowActions
event={event}
onEditIntake={onEditIntake}
onDeleteIntake={onDeleteIntake}
t={t}
/>
)}
</div>
</ListRow>
);
})}
</TableBody>
</Table>
</div>
)}
{totalPages > 1 && (
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
@@ -481,3 +573,58 @@ export function IntakeHistoryListV2({
</Card>
);
}
/**
* Per-row Bearbeiten/Löschen kebab, shared by the desktop table row and the
* mobile stacked-card row. v1.30 mobile audit (M-1) — was a hard `h-8 w-8`
* (32 px), sub-floor in an edit flow and inconsistent with the sort headers
* in this same file and the dose-history ledger's kebab; now `size-11
* sm:size-9` like the rest of the app's icon-button floor.
*/
function IntakeRowActions({
event,
onEditIntake,
onDeleteIntake,
t,
}: {
event: IntakeEvent;
onEditIntake?: (event: IntakeEvent) => void;
onDeleteIntake?: (eventId: string) => void;
t: ReturnType<typeof useTranslations>["t"];
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
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)}
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>
);
}
@@ -28,7 +28,7 @@
* common case. Dates convert to instants at the browser's local
* midnight, matching the user's wall clock like every schedule input.
*/
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
@@ -51,14 +51,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips";
@@ -421,6 +414,12 @@ function EraDialog({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.30 mobile audit (M-4) — the footer's submit Button lives outside the
// form's DOM subtree once the sheet swaps to `ResponsiveSheet` (the
// Sheet/Dialog primitive owns the footer slot), so it associates via the
// native `form` attribute rather than nesting inside `<form>` — the same
// pattern `log-intake-dialog.tsx` uses.
const formId = useId();
const [times, setTimes] = useState<string[]>(() =>
target ? eraTimes(target.entries) : [],
);
@@ -504,116 +503,116 @@ function EraDialog({
}
return (
<Dialog
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) reset();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{target
? t("medications.detail.zeitplan.history.editEraTitle")
: t("medications.detail.zeitplan.history.addEraTitle")}
</DialogTitle>
<DialogDescription>
{target
? t("medications.detail.zeitplan.history.editEraDescription")
: t("medications.detail.zeitplan.history.addEraDescription")}
</DialogDescription>
</DialogHeader>
{target?.source === "ARCHIVED" && (
<p
className="text-muted-foreground text-xs"
data-slot="zeitplan-history-archived-hint"
title={
target
? t("medications.detail.zeitplan.history.editEraTitle")
: t("medications.detail.zeitplan.history.addEraTitle")
}
description={
target
? t("medications.detail.zeitplan.history.editEraDescription")
: t("medications.detail.zeitplan.history.addEraDescription")
}
footer={
<>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
{t("medications.detail.zeitplan.history.archivedEditHint")}
{t("medications.detail.zeitplan.history.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-slot="zeitplan-history-submit"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<History className="size-4" aria-hidden="true" />
)}
{t("medications.detail.zeitplan.history.submit")}
</Button>
</>
}
>
{target?.source === "ARCHIVED" && (
<p
className="text-muted-foreground mb-3 text-xs"
data-slot="zeitplan-history-archived-hint"
>
{t("medications.detail.zeitplan.history.archivedEditHint")}
</p>
)}
<form
id={formId}
onSubmit={(e) => {
e.preventDefault();
if (create.isPending) return;
submit();
}}
className="space-y-4"
>
<div className="space-y-1.5">
<p className="text-sm font-medium">
{t("medications.detail.zeitplan.history.timesLabel")}
</p>
<TimesOfDayChips value={times} onChange={setTimes} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-from">
{t("medications.detail.zeitplan.history.fromLabel")}
</Label>
<DateField
id="zeitplan-history-from"
value={fromDate}
onChange={setFromDate}
data-testid="zeitplan-history-from"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-until">
{t("medications.detail.zeitplan.history.untilLabel")}
</Label>
<DateField
id="zeitplan-history-until"
value={untilDate}
onChange={setUntilDate}
data-testid="zeitplan-history-until"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.history.untilHint")}
</p>
{validationKey !== null && (
<p
className="text-destructive text-sm"
role="alert"
data-slot="zeitplan-history-validation"
>
{validationKey === "timesMissing" &&
t("medications.detail.zeitplan.history.validationTimes")}
{validationKey === "rangeMissing" &&
t("medications.detail.zeitplan.history.validationRangeMissing")}
{validationKey === "rangeOrder" &&
t("medications.detail.zeitplan.history.validationRangeOrder")}
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (create.isPending) return;
submit();
}}
className="space-y-4"
>
<div className="space-y-1.5">
<p className="text-sm font-medium">
{t("medications.detail.zeitplan.history.timesLabel")}
</p>
<TimesOfDayChips value={times} onChange={setTimes} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-from">
{t("medications.detail.zeitplan.history.fromLabel")}
</Label>
<DateField
id="zeitplan-history-from"
value={fromDate}
onChange={setFromDate}
data-testid="zeitplan-history-from"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-until">
{t("medications.detail.zeitplan.history.untilLabel")}
</Label>
<DateField
id="zeitplan-history-until"
value={untilDate}
onChange={setUntilDate}
data-testid="zeitplan-history-until"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.history.untilHint")}
</p>
{validationKey !== null && (
<p
className="text-destructive text-sm"
role="alert"
data-slot="zeitplan-history-validation"
>
{validationKey === "timesMissing" &&
t("medications.detail.zeitplan.history.validationTimes")}
{validationKey === "rangeMissing" &&
t("medications.detail.zeitplan.history.validationRangeMissing")}
{validationKey === "rangeOrder" &&
t("medications.detail.zeitplan.history.validationRangeOrder")}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
{t("medications.detail.zeitplan.history.cancel")}
</Button>
<Button
type="submit"
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-slot="zeitplan-history-submit"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<History className="size-4" aria-hidden="true" />
)}
{t("medications.detail.zeitplan.history.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</form>
</ResponsiveSheet>
);
}
@@ -450,7 +450,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
aria-checked={selected}
onClick={() => setEntry(candidate)}
{...getEntryRadioProps(index)}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
className={`flex min-h-11 items-center justify-center rounded-full border px-2.5 py-1 text-xs transition-colors sm:min-h-9 ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"