mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user