mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
66fe69b493
commit
5532a42ff6
7 files changed
+565
-397
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user