feat(modules): unify Vorsorge, Illness and Labs with the medication pattern

- add a wrench beside each module's add button linking to the module's own
  settings page; rename every add button to a plain "hinzufügen"
- give each module a settings page: view (cards/list) + manual reorder; Labs
  also gets a sort-order choice (by last reading) and biomarker CRUD/reorder
- collapse the Illness "abgeschlossen" list by default
- rebuild the Vorsorge card on the medication card's building blocks: a green
  "Jetzt messen" action, the interval as a chip beside the name, and an
  in-interval progress signal; drop the kebab "anpassen" and stray actions
- persist per-module view/order/sort client-side; add reverse-coverage-clean
  strings across all six locales
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 21:36:58 +02:00
1 parent ae3091df9e
commit 267dcad13d
22 files changed
+1834 -273

No files matched your search

@@ -0,0 +1,221 @@
"use client";
import { useId } from "react";
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.6 (W8 / MOD-03) — generic, controlled reorder list shared by the
* Vorsorge / Illness / Labs settings pages.
*
* A single section (no active/inactive split — the secondary modules have a
* flat list) with the same vocabulary as the medication order editor: an
* `@dnd-kit` drag handle (pointer + keyboard sensors) plus per-row up/down
* arrow buttons as the primary keyboard surface. Unlike the medication
* editor it is fully controlled — there is no Save/Cancel draft state
* because the host persists each move immediately to the localStorage order
* (the choice is presentational, not a server write). The swap-and-renumber
* logic is the shared `reorderById` helper so the order semantics match the
* dashboard / insights / medications surfaces exactly.
*/
export interface ReorderItem {
id: string;
/** Primary label (bold). */
name: string;
/** Optional secondary line (muted). */
secondary?: string;
}
interface ModuleOrderEditorProps {
/** The list in its current effective order. */
items: ReorderItem[];
/** Persist the full id order after every move. */
onChange: (order: string[]) => void;
}
export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) {
const { t } = useTranslations();
const dragHintId = useId();
const ids = items.map((i) => i.id);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const labels = {
moveUp: t("dashboard.moveUp"),
moveDown: t("dashboard.moveDown"),
dragHandle: t("dashboard.dragHandle"),
};
function applyReorder(fromId: string, toId: string) {
onChange(
reorderById(
ids.map((id, order) => ({ id, order })),
fromId,
toId,
).map((r) => r.id),
);
}
function move(id: string, delta: -1 | 1) {
const idx = ids.indexOf(id);
const targetIdx = idx + delta;
if (idx < 0 || targetIdx < 0 || targetIdx >= ids.length) return;
applyReorder(id, ids[targetIdx]);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
applyReorder(String(active.id), String(over.id));
}
if (items.length === 0) {
return (
<p className="text-muted-foreground text-sm">
{t("moduleList.reorder.empty")}
</p>
);
}
return (
<div className="space-y-2" data-slot="module-order-editor">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => (
<SortableRow
key={item.id}
item={item}
index={index}
total={items.length}
dragHintId={dragHintId}
labels={labels}
onMove={move}
/>
))}
</SortableContext>
</DndContext>
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
</div>
);
}
interface SortableRowProps {
item: ReorderItem;
index: number;
total: number;
dragHintId: string;
labels: { moveUp: string; moveDown: string; dragHandle: string };
onMove: (id: string, delta: -1 | 1) => void;
}
function SortableRow({
item,
index,
total,
dragHintId,
labels,
onMove,
}: SortableRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: prefersReducedMotion() ? "none" : transition,
};
return (
<div
ref={setNodeRef}
style={style}
data-slot="module-reorder-row"
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${item.name}`}
aria-describedby={dragHintId}
title={labels.dragHandle}
data-slot="module-drag-handle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing motion-reduce:transition-none"
>
<GripVertical className="h-4 w-4" />
</button>
<span className="min-w-0 flex-1 text-sm">
<span className="block truncate font-medium" title={item.name}>
{item.name}
</span>
{item.secondary ? (
<span className="text-muted-foreground block truncate text-xs">
{item.secondary}
</span>
) : null}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(item.id, -1)}
disabled={index === 0}
aria-label={`${labels.moveUp} — ${item.name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(item.id, 1)}
disabled={index === total - 1}
aria-label={`${labels.moveDown} — ${item.name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import type { ReactNode } from "react";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
/**
* v1.18.6 (W8 / MOD-03) — heading frame for the per-module settings pages
* (Vorsorge / Illness / Labs).
*
* FRAME-ADOPTION SEAM: a parallel phase is introducing a shared
* `SettingsSectionFrame` primitive under `src/components/settings/*` (which
* this phase must not edit). Until that lands, this renders the canonical
* settings heading markup verbatim — `h1.text-2xl.font-bold.tracking-tight`
* + `p.text-muted-foreground.text-sm` — so the three new pages already
* conform to the standard and can be swapped to the shared frame at merge by
* replacing this single wrapper. The back-link reuses the existing
* `SettingsHubBackLink` (read-only import) so the pages have a consistent
* "return to the hub" affordance even though they are not listed in the
* settings-shell sidebar.
*/
export function ModuleSettingsFrame({
title,
description,
backHref,
backLabelKey,
children,
}: {
title: string;
description: string;
/** Where the "← back" link returns to — the module's own page. */
backHref: string;
backLabelKey: string;
children: ReactNode;
}) {
return (
<div className="space-y-6">
<div className="space-y-1">
<SettingsHubBackLink href={backHref} labelKey={backLabelKey} />
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
{children}
</div>
);
}
@@ -0,0 +1,69 @@
"use client";
import { LayoutGrid, List as ListIcon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ModuleListView } from "@/lib/module-list-prefs";
/**
* v1.18.6 (W8 / MOD-03) — icon-only card/list view toggle shared by the
* Vorsorge / Illness / Labs settings pages. Mirrors the medication module's
* `MedicationViewToggle` grammar (two ghost segments, the active one filled,
* `aria-pressed` per segment) but switches between a card grid and a compact
* list rather than cards-vs-table, so the label set is `moduleList.view*`.
*/
interface ModuleViewToggleProps {
view: ModuleListView;
onChange: (view: ModuleListView) => void;
}
export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) {
const { t } = useTranslations();
const segments = [
{
value: "cards" as const,
label: t("moduleList.viewCards"),
icon: LayoutGrid,
},
{
value: "list" as const,
label: t("moduleList.viewList"),
icon: ListIcon,
},
];
return (
<div
role="group"
aria-label={t("moduleList.viewToggleLabel")}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
>
{segments.map(({ value, label, icon: Icon }) => {
const active = view === value;
return (
<button
key={value}
type="button"
onClick={() => {
if (!active) onChange(value);
}}
aria-pressed={active}
aria-label={label}
title={label}
data-slot={`module-view-${value}`}
className={cn(
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
);
}