feat(meds): step content for the modal wizard

Each of the eight steps lands with its Lucide icon, title, subline
and a single focused field. The cadence radio rows carry the
plain-language description directly under each label (not in a
tooltip), and the rolling-cadence row gets the literal mental-model
line so the patient understands the counter resets on every log.
Step 2 surfaces ten taxonomy rows that each map onto a single
(treatmentClass, category) pair through a hard-coded table; Step 6
swaps its body on the cadence kind picked in Step 5; Step 7 wraps
the existing times-of-day chips primitive with a sunrise / sun /
sunset / moon preset row above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 19:33:11 +02:00
1 parent 0a0af4b772
commit b1f9a4bf8f
8 files changed
+657 -17

No files matched your search

@@ -1,5 +1,9 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import type { WizardPayload } from "../wizard-payload";
export interface StepProps {
@@ -7,6 +11,24 @@ export interface StepProps {
applyPartial: (partial: Partial<WizardPayload>) => void;
}
export function Step1Name(_props: StepProps) {
return null;
export function Step1Name({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
return (
<div className="space-y-1.5" data-slot="wizard-step1">
<Label htmlFor="wizard-name" className="sr-only">
{t("medications.wizard.steps.step1.label")}
</Label>
<Input
id="wizard-name"
value={payload.name}
onChange={(e) => applyPartial({ name: e.target.value })}
placeholder={t("medications.wizard.steps.step1.placeholder")}
maxLength={100}
autoCapitalize="words"
autoComplete="off"
enterKeyHint="next"
className="h-11"
/>
</div>
);
}
@@ -1,7 +1,89 @@
"use client";
import {
Activity,
Apple,
Droplet,
Flame,
Leaf,
type LucideIcon,
type LucideProps,
ShieldCheck,
Stethoscope,
Syringe,
Tag,
Wind,
} from "lucide-react";
import type { ComponentType } from "react";
import { useTranslations } from "@/lib/i18n/context";
import {
type WizardTreatmentRow,
WIZARD_TREATMENT_ROWS,
} from "../wizard-payload";
import type { StepProps } from "./Step1Name";
export function Step2Class(_props: StepProps) {
return null;
// Each row's Lucide glyph. Marc-confirmed assignment in D-1 §3 Step 2.
const ROW_ICONS: Record<
WizardTreatmentRow,
ComponentType<LucideProps>
> = {
bloodPressure: Stethoscope,
diabetes: Droplet,
hormone: Activity,
glp1: Syringe,
painRelief: Flame,
allergy: Wind,
vitamin: Apple,
supplement: Leaf,
antibiotic: ShieldCheck,
other: Tag as unknown as LucideIcon,
};
export function Step2Class({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
return (
<div
role="radiogroup"
aria-label={t("medications.wizard.steps.step2.title")}
className="space-y-2"
data-slot="wizard-step2"
>
{WIZARD_TREATMENT_ROWS.map((row) => {
const Icon = ROW_ICONS[row];
const selected = payload.treatmentRow === row;
const label = t(`medications.wizard.classRow.${row}`);
return (
<label
key={row}
className={[
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
data-slot="wizard-class-row"
data-row={row}
data-selected={selected ? "true" : "false"}
>
<input
type="radio"
name="wizard-class"
value={row}
checked={selected}
onChange={() => applyPartial({ treatmentRow: row })}
className="sr-only"
aria-label={label}
/>
<Icon
className="text-primary h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span className="text-sm font-medium">{label}</span>
</label>
);
})}
</div>
);
}
@@ -1,7 +1,79 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import type { StepProps } from "./Step1Name";
export function Step3Dose(_props: StepProps) {
return null;
/**
* Dose unit choices, ordered so mg / ml / IE / µg lead and the
* device-form units (Tablette, Hub, Sprühstoß, …) follow. Mirrors the
* v1.5.3 list with the Marc-requested order tightening.
*/
const DOSE_UNIT_KEYS = [
"mg",
"ml",
"iu",
"mcg",
"g",
"tablets",
"capsules",
"drops",
"puffs",
"sprays",
"pieces",
"other",
] as const;
export function Step3Dose({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
return (
<div className="grid grid-cols-2 gap-3" data-slot="wizard-step3">
<div className="space-y-1.5">
<Label htmlFor="wizard-dose-amount" className="text-sm">
{t("medications.wizard.steps.step3.amountLabel")}
</Label>
<Input
id="wizard-dose-amount"
type="text"
inputMode="decimal"
value={payload.doseAmount}
onChange={(e) => applyPartial({ doseAmount: e.target.value })}
placeholder={t("medications.wizard.steps.step3.amountPlaceholder")}
maxLength={20}
autoComplete="off"
enterKeyHint="next"
className="h-11"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wizard-dose-unit" className="text-sm">
{t("medications.wizard.steps.step3.unitLabel")}
</Label>
<Select
value={payload.doseUnit}
onValueChange={(v) => applyPartial({ doseUnit: v })}
>
<SelectTrigger id="wizard-dose-unit" className="h-11 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DOSE_UNIT_KEYS.map((u) => (
<SelectItem key={u} value={u}>
{t(`medications.wizard.steps.step3.unit.${u}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
@@ -1,7 +1,30 @@
"use client";
import { CourseWindowRow } from "@/components/medications/scheduling/CourseWindowRow";
import type { StepProps } from "./Step1Name";
export function Step4Window(_props: StepProps) {
return null;
/**
* Step 4 — Behandlungszeitraum (course window).
*
* On the one-shot path (Step 5 set `mode = "oneShot"`) the endsOn
* stays pinned to the startsOn via `lockEndsToStart`. Before Step 5
* runs, the mode is still `null` so the unlocked form renders by
* default — this matches the wizard's "you can change anything later"
* stance.
*/
export function Step4Window({ payload, applyPartial }: StepProps) {
const isOneShot = payload.mode === "oneShot";
return (
<div data-slot="wizard-step4">
<CourseWindowRow
startsOn={payload.startsOn}
endsOn={payload.endsOn}
lockEndsToStart={isOneShot}
onChange={({ startsOn, endsOn }) =>
applyPartial({ startsOn, endsOn })
}
/>
</div>
);
}
@@ -1,7 +1,116 @@
"use client";
import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
import type { CadenceKind } from "@/components/medications/scheduling/types";
import { useTranslations } from "@/lib/i18n/context";
import type { WizardPayload } from "../wizard-payload";
import type { StepProps } from "./Step1Name";
export function Step5Cadence(_props: StepProps) {
/**
* The six cadence rows surfaced in Step 5. Each maps to a (mode,
* cadenceKind) decision. "Einmalig" flips the wizard onto the
* one-shot path; every other row stays recurring.
*/
type Step5Row =
| "daily"
| "weekdays"
| "everyNWeeks"
| "monthly"
| "rolling"
| "oneShot";
const STEP5_ROWS: readonly Step5Row[] = [
"daily",
"weekdays",
"everyNWeeks",
"monthly",
"rolling",
"oneShot",
];
function rowFromPayload(payload: WizardPayload): Step5Row | null {
if (payload.mode === "oneShot") return "oneShot";
if (payload.mode === "recurring") {
switch (payload.cadence.kind) {
case "daily":
case "weekdays":
case "everyNWeeks":
case "monthly":
case "rolling":
return payload.cadence.kind;
default:
return null;
}
}
return null;
}
export function Step5Cadence({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
const current = rowFromPayload(payload);
return (
<div
role="radiogroup"
aria-label={t("medications.wizard.steps.step5.title")}
className="space-y-2"
data-slot="wizard-step5"
>
{STEP5_ROWS.map((row) => {
const selected = current === row;
const label = t(`medications.wizard.cadence.${row}.label`);
const description = t(`medications.wizard.cadence.${row}.description`);
return (
<label
key={row}
className={[
"block min-h-11 cursor-pointer rounded-md border p-3 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
data-slot="wizard-cadence-row"
data-row={row}
data-selected={selected ? "true" : "false"}
>
<input
type="radio"
name="wizard-cadence"
value={row}
checked={selected}
onChange={() => applyRowPick(row, payload, applyPartial)}
className="sr-only"
aria-label={label}
/>
<div className="text-sm font-medium">{label}</div>
<p className="text-muted-foreground mt-0.5 text-xs">
{description}
</p>
</label>
);
})}
</div>
);
}
function applyRowPick(
row: Step5Row,
payload: WizardPayload,
applyPartial: (partial: Partial<WizardPayload>) => void,
) {
if (row === "oneShot") {
applyPartial({
mode: "oneShot",
cadence: encodeCadence("oneShot", payload.subControls),
// One-shot pins endsOn to startsOn; the route enforces the
// same invariant on the server side.
endsOn: payload.startsOn,
});
return;
}
const cadenceKind: CadenceKind = row;
applyPartial({
mode: "recurring",
cadence: encodeCadence(cadenceKind, payload.subControls),
});
}
@@ -1,7 +1,188 @@
"use client";
import { useMemo } from "react";
import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
type CadenceSubControls,
type WeekdayToken,
WEEKDAY_TOKENS,
} from "@/components/medications/scheduling/types";
import { useTranslations } from "@/lib/i18n/context";
import type { StepProps } from "./Step1Name";
export function Step6SubCadence(_props: StepProps) {
/**
* Step 6 — sub-cadence detail. The body swaps on the cadence kind the
* user picked in Step 5. Daily and one-shot skip Step 6 entirely (the
* path table omits the index), so this component only branches on the
* four kinds that have additional detail: weekdays, everyNWeeks,
* monthly, rolling.
*
* Each branch keeps the `<CadenceSubControls>` shape the picker
* primitive uses, so the encoded RRULE / rollingIntervalDays the
* wizard emits matches what the existing edit form would produce for
* the same inputs.
*/
export function Step6SubCadence({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
const kind = payload.cadence.kind;
function patchSub(patch: Partial<CadenceSubControls>) {
const nextSub = { ...payload.subControls, ...patch };
applyPartial({
subControls: nextSub,
cadence: encodeCadence(kind, nextSub),
});
}
if (kind === "weekdays" || kind === "everyNWeeks") {
return (
<div className="space-y-3" data-slot="wizard-step6">
{kind === "everyNWeeks" && (
<div className="flex items-center gap-2">
<Label htmlFor="wizard-interval-weeks" className="text-sm">
{t("medications.wizard.steps.step6.intervalWeeks.label")}
</Label>
<Input
id="wizard-interval-weeks"
type="number"
inputMode="numeric"
min={1}
max={12}
value={payload.subControls.intervalWeeks}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n >= 1 && n <= 12) {
patchSub({ intervalWeeks: Math.trunc(n) });
}
}}
className="h-11 w-20"
/>
<span className="text-sm">
{t("medications.wizard.steps.step6.intervalWeeks.suffix")}
</span>
</div>
)}
<WeekdayChips
selected={payload.subControls.weekdays}
onChange={(weekdays) => patchSub({ weekdays })}
/>
</div>
);
}
if (kind === "monthly") {
return (
<div className="flex items-center gap-2" data-slot="wizard-step6">
<Label htmlFor="wizard-day-of-month" className="text-sm">
{t("medications.wizard.steps.step6.dayOfMonth.label")}
</Label>
<Input
id="wizard-day-of-month"
type="number"
inputMode="numeric"
min={1}
max={31}
value={payload.subControls.dayOfMonth}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n >= 1 && n <= 31) {
patchSub({ dayOfMonth: Math.trunc(n) });
}
}}
className="h-11 w-20"
/>
<span className="text-sm">
{t("medications.wizard.steps.step6.dayOfMonth.suffix")}
</span>
</div>
);
}
if (kind === "rolling") {
return (
<div className="flex items-center gap-2" data-slot="wizard-step6">
<Label htmlFor="wizard-rolling-days" className="text-sm">
{t("medications.wizard.steps.step6.rollingDays.label")}
</Label>
<Input
id="wizard-rolling-days"
type="number"
inputMode="numeric"
min={1}
max={365}
value={payload.subControls.rollingDays}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n) && n >= 1 && n <= 365) {
patchSub({ rollingDays: Math.trunc(n) });
}
}}
className="h-11 w-20"
/>
<span className="text-sm">
{t("medications.wizard.steps.step6.rollingDays.suffix")}
</span>
</div>
);
}
return null;
}
interface WeekdayChipsProps {
selected: readonly WeekdayToken[];
onChange: (next: WeekdayToken[]) => void;
}
function WeekdayChips({ selected, onChange }: WeekdayChipsProps) {
const { t } = useTranslations();
const set = useMemo(() => new Set(selected), [selected]);
function toggle(tok: WeekdayToken) {
const next = new Set(set);
if (next.has(tok)) next.delete(tok);
else next.add(tok);
onChange(WEEKDAY_TOKENS.filter((w) => next.has(w)));
}
return (
<div
role="group"
aria-label={t("medications.scheduling.cadence.weekdays.label")}
className="flex flex-wrap gap-1.5"
data-slot="wizard-weekday-chips"
>
{WEEKDAY_TOKENS.map((tok) => {
const isOn = set.has(tok);
const short = t(
`medications.scheduling.cadence.weekdays.short.${tok.toLowerCase()}`,
);
const long = t(
`medications.scheduling.cadence.weekdays.long.${tok.toLowerCase()}`,
);
return (
<button
key={tok}
type="button"
aria-pressed={isOn}
aria-label={long}
data-slot="wizard-weekday-chip"
data-token={tok}
data-active={isOn ? "true" : "false"}
onClick={() => toggle(tok)}
className={[
"focus-visible:ring-ring inline-flex h-11 min-w-11 items-center justify-center rounded-md border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
].join(" ")}
>
{short}
</button>
);
})}
</div>
);
}
@@ -1,7 +1,83 @@
"use client";
import { Moon, Sun, Sunrise, Sunset } from "lucide-react";
import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips";
import { useTranslations } from "@/lib/i18n/context";
import type { StepProps } from "./Step1Name";
export function Step7Times(_props: StepProps) {
return null;
/**
* Step 7 — Einnahmezeit(en). Reuses the v1.5 `TimesOfDayChips`
* primitive. The preset row of sunrise / sun / sunset / moon icons
* toggles 08:00 / 12:00 / 18:00 / 22:00 — same semantics as the
* chips' own preset row, surfaced here as a visual continuation of
* the wizard's icon language.
*/
const PRESETS = [
{ time: "08:00", icon: Sunrise, key: "morning" },
{ time: "12:00", icon: Sun, key: "noon" },
{ time: "18:00", icon: Sunset, key: "evening" },
{ time: "22:00", icon: Moon, key: "night" },
] as const;
export function Step7Times({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
function togglePreset(time: string) {
const has = payload.timesOfDay.includes(time);
if (has) {
applyPartial({
timesOfDay: payload.timesOfDay.filter((x) => x !== time),
});
} else {
applyPartial({
timesOfDay: [...payload.timesOfDay, time].sort((a, b) =>
a.localeCompare(b),
),
});
}
}
return (
<div className="space-y-3" data-slot="wizard-step7">
<div
role="group"
aria-label={t("medications.wizard.steps.step7.presetsLabel")}
className="flex flex-wrap gap-1.5"
data-slot="wizard-preset-row"
>
{PRESETS.map(({ time, icon: Icon, key }) => {
const isOn = payload.timesOfDay.includes(time);
const label = t(
`medications.wizard.steps.step7.presets.${key}`,
);
return (
<button
key={time}
type="button"
aria-pressed={isOn}
aria-label={`${label} ${time}`}
data-slot="wizard-preset-chip"
data-preset={key}
data-active={isOn ? "true" : "false"}
onClick={() => togglePreset(time)}
className={[
"focus-visible:ring-ring inline-flex h-11 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2",
isOn
? "bg-primary text-primary-foreground border-primary"
: "border-border bg-background text-foreground hover:bg-muted",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
<span>{time}</span>
</button>
);
})}
</div>
<TimesOfDayChips
value={payload.timesOfDay}
onChange={(timesOfDay) => applyPartial({ timesOfDay })}
maxChips={8}
/>
</div>
);
}
@@ -1,8 +1,13 @@
"use client";
import type {
MedicationPayload,
WizardPayload,
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
type MedicationPayload,
summariseCadence,
type WizardPayload,
} from "../wizard-payload";
export interface Step8SummaryProps {
@@ -13,6 +18,76 @@ export interface Step8SummaryProps {
submitError: string | null;
}
export function Step8Summary(_props: Step8SummaryProps) {
return null;
export function Step8Summary({
payload,
applyPartial,
mode,
initial,
submitError,
}: Step8SummaryProps) {
const { t } = useTranslations();
const summary = summariseCadence(payload, t);
// v1.5.4 ships single-schedule editing only. A medication that
// already carries multiple schedules surfaces a note pointing at
// the legacy detail form; the multi-schedule compose-mode lands in
// v1.5.5 per the design hand-off (D-1 §10 deferred bullet 2).
const isMultiSchedule =
mode === "edit" && (initial?.schedules.length ?? 0) > 1;
return (
<div className="space-y-4" data-slot="wizard-step8">
<div
className="bg-muted/40 rounded-md border p-3 text-sm"
data-slot="wizard-summary"
>
<p className="mb-1 font-medium">
{t("medications.wizard.summary.title")}
</p>
<p>{summary}</p>
</div>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label
htmlFor="wizard-reminders"
className="text-sm font-medium"
>
{t("medications.wizard.steps.step8.remindersLabel")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.wizard.steps.step8.remindersDescription")}
</p>
</div>
<Switch
id="wizard-reminders"
checked={payload.notificationsEnabled}
onCheckedChange={(checked) =>
applyPartial({ notificationsEnabled: checked })
}
data-slot="wizard-reminders-toggle"
aria-label={t("medications.wizard.steps.step8.remindersLabel")}
/>
</div>
{isMultiSchedule && (
<p
className="text-muted-foreground border-border/70 rounded-md border border-dashed p-3 text-xs"
data-slot="wizard-multi-schedule-note"
>
{t("medications.wizard.steps.step8.multiScheduleNote")}
</p>
)}
{submitError && (
<p
className="text-destructive text-sm"
role="alert"
data-slot="wizard-submit-error"
>
{submitError}
</p>
)}
</div>
);
}